Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
fa9de5a
feat: add Google Business Profile platform enum and config
paulocastellano Aug 11, 2026
0f5cdbf
feat: add GoogleBusinessPost content type
paulocastellano Aug 11, 2026
ddba0b1
feat: add Google Business Profile post meta validation rules
paulocastellano Aug 11, 2026
6687f83
feat: add GoogleBusinessPublishException error mapping
paulocastellano Aug 11, 2026
823ffc8
test: add missing INVALID_ARGUMENT error mapping test
paulocastellano Aug 11, 2026
89081de
feat: add GoogleBusinessPublisher for Local Post publishing
paulocastellano Aug 11, 2026
cf0423f
feat: add GoogleBusinessAnalytics location performance metrics
paulocastellano Aug 11, 2026
25c7279
fix: translate Google Business Profile metrics keys to all locales
paulocastellano Aug 11, 2026
0336dff
feat: verify and refresh Google Business Profile tokens
paulocastellano Aug 11, 2026
d3c80ff
feat: dispatch Google Business Profile posts through the publish job
paulocastellano Aug 11, 2026
2aede14
feat: add Google Business Profile OAuth connect flow
paulocastellano Aug 11, 2026
a37b280
feat: add Google Business Profile location picker page
paulocastellano Aug 11, 2026
4c3709e
feat: add Google Business Profile composer settings card
paulocastellano Aug 11, 2026
ea89890
fix: add ContentType.GoogleBusinessPost to frontend content-type enum
paulocastellano Aug 11, 2026
467cfb6
feat: wire Google Business Profile settings into the post composer
paulocastellano Aug 11, 2026
f18cfca
feat: add Google Business Profile post preview
paulocastellano Aug 11, 2026
8887b3f
fix: stop Google Business call_to_action.url rule blocking every plat…
paulocastellano Aug 11, 2026
f471a39
test: cover Google Business Profile meta across web, API, and MCP ent…
paulocastellano Aug 11, 2026
ce4016f
fix: remove redundant ReflectionMethod import in PostPlatformMetaRule…
paulocastellano Aug 11, 2026
4075623
fix: add GoogleBusiness case to MediaOptimizer image config match
paulocastellano Aug 11, 2026
69cab53
fix: register Google Business Profile in the frontend platform enum a…
paulocastellano Aug 11, 2026
ce92c83
fix: store the full accounts/*/locations/* name for the v4 Local Post…
paulocastellano Aug 11, 2026
74b4f0c
fix: wire Google Business Profile analytics into the analytics contro…
paulocastellano Aug 11, 2026
f31b840
fix: gate Google Business Profile publish on its required meta client…
paulocastellano Aug 11, 2026
83f3a43
fix: translate the Google Business Profile preview CTA labels
paulocastellano Aug 11, 2026
d84e137
fix: add the Google Business Profile arm to SocialAccount::profile_url
paulocastellano Aug 11, 2026
5251ffb
fix: send the event object for Google Business OFFER posts too
paulocastellano Aug 11, 2026
9264b35
fix: scope Google Business Profile validation errors to their own loc…
paulocastellano Aug 11, 2026
6c6b261
fix: forward the reconnect id through the Google Business OAuth callback
paulocastellano Aug 11, 2026
df3c56f
refactor: share the Google Business CTA action-type list between edit…
paulocastellano Aug 11, 2026
02040a5
chore: drop the unused GoogleBusinessPublisher::getLocations()
paulocastellano Aug 11, 2026
49c156e
fix: harden and de-duplicate the Google Business connect flow
paulocastellano Aug 11, 2026
a4de84e
fix: treat an explicitly null Google Business CTA action type as no CTA
paulocastellano Aug 11, 2026
b221b7a
perf: cache Google Business Profile location metrics like the sibling…
paulocastellano Aug 11, 2026
515d7da
refactor: share the Google Business dashboard URL builder between the…
paulocastellano Aug 11, 2026
6d9261c
feat: add the Google Business Profile analytics dashboard panel
paulocastellano Aug 11, 2026
06a21a3
fix: render the Google Business Profile tile and its default content …
paulocastellano Aug 11, 2026
3f1d5a4
fix: treat an explicitly null Google Business topic type as STANDARD
paulocastellano Aug 11, 2026
2743dc7
fix: reject blank Google Business event dates instead of publishing t…
paulocastellano Aug 11, 2026
09bf855
fix: clear the Google Business OAuth session on the unknown-location …
paulocastellano Aug 11, 2026
e17aa3e
docs: add Google Business Profile to the platform API documentation list
paulocastellano Aug 11, 2026
c60d0a6
fix: reject a blank Google Business event title instead of publishing it
paulocastellano Aug 11, 2026
7c02090
fix: guard the Google Business verify call against a missing location…
paulocastellano Aug 11, 2026
903f6b9
refactor: share the Google Business short-to-full location name conve…
paulocastellano Aug 11, 2026
a6dbd8e
test: cover the untested Google Business meta fields across the API a…
paulocastellano Aug 11, 2026
301f908
fix: replace the Google Business Profile placeholder icon with the re…
paulocastellano Aug 11, 2026
8e65ddc
docs: record durable rules for exhaustive Platform matches and per-pl…
paulocastellano Aug 11, 2026
2b06499
Merge branch 'main' into worktree-google-business-profile
paulocastellano Aug 16, 2026
4d66433
Merge branch 'main' into worktree-google-business-profile
paulocastellano Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .ai/rules/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Project Rules Index

Before planning or editing, find the row whose globs match the file's path and read that rule file.

| Applies to | Rule file |
| --- | --- |
| app/Enums/SocialAccount/Platform.php | .ai/rules/social-account.md |
| app/Support/PostPlatformMetaRules.php | .ai/rules/support.md |
9 changes: 9 additions & 0 deletions .ai/rules/social-account.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
paths:
- app/Enums/SocialAccount/Platform.php
---

# Social Account

## Adding a platform: grep for exhaustive Platform matches beyond the known touch-point list
Adding a new Platform enum case breaks any `match ($platform) { ... }` elsewhere in the codebase that enumerates every case with no `default` arm — these throw UnhandledMatchError only at runtime/test time, not statically. Known example found the hard way: `app/Services/Media/MediaOptimizer.php`'s per-platform image optimization settings match, which isn't part of the "usual" platform touch-point list (Platform enum, ContentType enum, config, PostPlatformMetaRules, publisher, controller, frontend registry). Before considering a new platform done, run the full test suite (`php artisan test --compact --parallel`) — a missing arm surfaces as a clean, unambiguous UnhandledMatchError failure, not a silent bug.
9 changes: 9 additions & 0 deletions .ai/rules/support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
paths:
- app/Support/PostPlatformMetaRules.php
---

# Support

## Never use a cross-field Laravel rule (required_unless, required_if, etc.) for a single platform's conditional meta field
`rules()` is shared by every platform via `platforms.*.meta.*` wildcards. Rules like `required_unless`/`required_if` are Laravel "implicit" rules — they validate even when the field itself is absent from the request. Adding one scoped in spirit to a single platform (e.g. `call_to_action.url` required_unless action_type is NONE/CALL, meant only for Google Business Profile) breaks every OTHER platform's create/update through web, API, and MCP, because their requests never send that field at all and the implicit rule still fires. The correct pattern (already used by Pinterest's `board_id`, Discord's `channel_id`): keep the field's `rules()` entry unconditional (`sometimes|nullable|...`), and enforce "required" semantics only in `requiredMetaViolation()`'s `match(true)` block, which is evaluated per the resolved `Platform` of the row being checked. This bug shipped once (fixed in commit 8887b3f3) and broke Pinterest/Discord/TikTok post updates — the task reviewer that approved the original `rules()` entry didn't catch it because it only reviewed Google Business's own test coverage, not sibling platforms'.
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ GOOGLE_CLIENT_SECRET=
GOOGLE_CLIENT_REDIRECT="${APP_URL}/accounts/youtube/callback"
GOOGLE_AUTH_CALLBACK="${APP_URL}/auth/google/callback"

# Google Business Profile (https://console.cloud.google.com)
# Dedicated OAuth app, isolated from YouTube
GOOGLE_BUSINESS_CLIENT_ID=
GOOGLE_BUSINESS_CLIENT_SECRET=
GOOGLE_BUSINESS_CLIENT_REDIRECT="${APP_URL}/accounts/google-business/callback"

# GitHub (https://github.com/settings/developers)
# Used for GitHub login/signup
GITHUB_AUTH_ENABLED=false
Expand Down Expand Up @@ -228,6 +234,7 @@ NIGHTWATCH_TOKEN=
# MASTODON_ENABLED=true
# BLUESKY_ENABLED=true
# TELEGRAM_ENABLED=true
# GOOGLE_BUSINESS_ENABLED=true

# Media Services
UNSPLASH_ACCESS_KEY=
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ Standing constraints:
- **Bluesky / AT Protocol**: official lexicons — https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo ; HTTP API reference — https://docs.bsky.app
- **Discord**: Webhook resource (used for our webhook-based publishing) — https://docs.discord.com/developers/resources/webhook
- **Telegram**: Bot API — https://core.telegram.org/bots/api
- **Google Business Profile**: Business Information API, Account Management API, Business Profile Performance API — https://developers.google.com/my-business/reference/rest ; legacy but still-active Local Posts v4 API (the only endpoint for creating/updating/deleting Local Posts) — https://developers.google.com/my-business/reference/rest/v4/accounts.locations.localPosts

## TryPost.it Documentation

Expand Down
9 changes: 9 additions & 0 deletions app/Enums/PostPlatform/ContentType.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ enum ContentType: string
// Discord
case DiscordMessage = 'discord_message';

// Google Business Profile
case GoogleBusinessPost = 'google_business_post';

/**
* AI generation format for an Instagram carousel. Not a content type —
* carousel posts are persisted as InstagramFeed.
Expand Down Expand Up @@ -84,6 +87,7 @@ public function label(): string
self::MastodonPost => 'Post',
self::TelegramPost => 'Post',
self::DiscordMessage => 'Message',
self::GoogleBusinessPost => 'Post',
};
}

Expand All @@ -108,6 +112,7 @@ public function platform(): SocialPlatform
self::MastodonPost => SocialPlatform::Mastodon,
self::TelegramPost => SocialPlatform::Telegram,
self::DiscordMessage => SocialPlatform::Discord,
self::GoogleBusinessPost => SocialPlatform::GoogleBusiness,
};
}

Expand Down Expand Up @@ -177,6 +182,7 @@ public function maxMediaCount(): int
self::MastodonPost => 4,
self::TelegramPost => 10,
self::DiscordMessage => 10,
self::GoogleBusinessPost => 1,
};
}

Expand Down Expand Up @@ -458,6 +464,7 @@ public function supportsVideo(): bool
self::MastodonPost => true,
self::TelegramPost => true,
self::DiscordMessage => true,
self::GoogleBusinessPost => false,
};
}

Expand Down Expand Up @@ -526,6 +533,7 @@ public function requiresMedia(): bool
self::TelegramPost => false,
self::FacebookPost => false,
self::DiscordMessage => false,
self::GoogleBusinessPost => false,
default => true,
};
}
Expand Down Expand Up @@ -609,6 +617,7 @@ public static function defaultFor(SocialPlatform $platform): self
SocialPlatform::Mastodon => self::MastodonPost,
SocialPlatform::Telegram => self::TelegramPost,
SocialPlatform::Discord => self::DiscordMessage,
SocialPlatform::GoogleBusiness => self::GoogleBusinessPost,
};
}
}
18 changes: 16 additions & 2 deletions app/Enums/SocialAccount/Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ enum Platform: string
case Mastodon = 'mastodon';
case Telegram = 'telegram';
case Discord = 'discord';
case GoogleBusiness = 'google_business';

/**
* The social network this platform belongs to. Variants that represent the
Expand Down Expand Up @@ -69,6 +70,7 @@ public function label(): string
self::Mastodon => 'Mastodon',
self::Telegram => 'Telegram',
self::Discord => 'Discord',
self::GoogleBusiness => 'Google Business Profile',
};
}

Expand All @@ -88,6 +90,7 @@ public function color(): string
self::Mastodon => '#6364FF',
self::Telegram => '#26A5E4',
self::Discord => '#5865F2',
self::GoogleBusiness => '#4285F4',
};
}

Expand All @@ -106,6 +109,7 @@ public function allowedMediaTypes(): array
self::Mastodon => [MediaType::Image, MediaType::Video],
self::Telegram => [MediaType::Image, MediaType::Video],
self::Discord => [MediaType::Image, MediaType::Video],
self::GoogleBusiness => [MediaType::Image],
};
}

Expand All @@ -124,6 +128,7 @@ public function maxImages(): int
self::Mastodon => 4,
self::Telegram => 10,
self::Discord => 10,
self::GoogleBusiness => 1,
};
}

Expand All @@ -147,7 +152,7 @@ public function altTextMaxLength(): ?int
self::Threads => 1000,
self::Pinterest => 500,
self::Discord => 1024,
self::TikTok, self::YouTube, self::Telegram => null,
self::TikTok, self::YouTube, self::Telegram, self::GoogleBusiness => null,
};
}

Expand Down Expand Up @@ -181,6 +186,7 @@ public function supportsAltText(): bool
* - Mastodon: 500 default; instances may be higher (we stay conservative)
* - Telegram: 4096 for a text message (media captions are capped at 1024,
* handled in the publisher by sending long text as its own message)
* - Google Business Profile Local Post `summary`: 1500
*/
public function maxContentLength(): int
{
Expand All @@ -197,6 +203,7 @@ public function maxContentLength(): int
self::Mastodon => 500,
self::Telegram => 4096,
self::Discord => 2000,
self::GoogleBusiness => 1500,
};
}

Expand Down Expand Up @@ -242,6 +249,9 @@ public function recommendedAiContentLength(): int
self::Telegram => 400,
// Discord — conversational community posts read best when concise
self::Discord => 280,
// Google Business Profile — image does most of the work, keep the
// summary tight and scannable
self::GoogleBusiness => 300,
};
}

Expand All @@ -265,6 +275,7 @@ public function requiredPublishScopes(): array
self::Mastodon => ['write:statuses'],
self::Telegram => [],
self::Discord => [],
self::GoogleBusiness => ['https://www.googleapis.com/auth/business.manage'],
};
}

Expand All @@ -283,6 +294,7 @@ public function supportsTextOnly(): bool
self::Mastodon => true,
self::Telegram => true,
self::Discord => true,
self::GoogleBusiness => true,
};
}

Expand Down Expand Up @@ -324,7 +336,7 @@ public function hasTokenRefreshFlow(): bool
return match ($this) {
self::LinkedIn, self::LinkedInPage, self::X, self::Bluesky,
self::YouTube, self::TikTok, self::Pinterest,
self::Threads, self::Instagram => true,
self::Threads, self::Instagram, self::GoogleBusiness => true,
default => false,
};
}
Expand Down Expand Up @@ -352,6 +364,7 @@ public static function accessTokenExtendingPlatformValues(): array
*
* - X: a 2-hour access token.
* - Instagram / Threads: Meta's 60-day long-lived token.
* - Google Business Profile: standard Google OAuth2 1-hour access token.
*
* Networks that always return expires_in (LinkedIn, TikTok, YouTube,
* Pinterest), whose refresh sets a fixed lifetime directly (Bluesky), or
Expand All @@ -362,6 +375,7 @@ public function defaultTokenTtlSeconds(): ?int
{
return match ($this) {
self::X => 7200,
self::GoogleBusiness => 3600,
self::Instagram, self::Threads => 5184000,
default => null,
};
Expand Down
96 changes: 96 additions & 0 deletions app/Exceptions/Social/GoogleBusinessPublishException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace App\Exceptions\Social;

use App\Exceptions\TokenExpiredException;
use Illuminate\Http\Client\Response;

class GoogleBusinessPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$status = $response->status();
$reason = (string) data_get($response->json(), 'error.status', '');
$message = (string) data_get($response->json(), 'error.message', '');
$rawResponse = $response->body();

if (self::isConfirmedDeadToken($response)) {
throw new TokenExpiredException(
message: $message !== '' ? $message : 'Google Business Profile access token has expired or been revoked',
platformErrorCode: $reason !== '' ? $reason : (string) $status,
);
}

if ($reason === 'PERMISSION_DENIED') {
return new static(
userMessage: 'Permission denied. Please reconnect and confirm access to this business location.',
category: ErrorCategory::Permission,
platformErrorCode: $reason,
rawResponse: $rawResponse,
);
}

if ($reason === 'NOT_FOUND') {
return new static(
userMessage: 'Business location not found. It may have been deleted — please reconnect.',
category: ErrorCategory::ContentPolicy,
platformErrorCode: $reason,
rawResponse: $rawResponse,
);
}

if ($reason === 'INVALID_ARGUMENT') {
return new static(
userMessage: $message !== '' ? $message : 'Invalid post content. Please check your post details.',
category: ErrorCategory::ContentPolicy,
platformErrorCode: $reason,
rawResponse: $rawResponse,
);
}

if ($reason === 'RESOURCE_EXHAUSTED' || $status === 429) {
return new static(
userMessage: 'Rate limit exceeded. Please try again later.',
category: ErrorCategory::RateLimit,
platformErrorCode: $reason !== '' ? $reason : (string) $status,
rawResponse: $rawResponse,
);
}

if ($status >= 500) {
return new static(
userMessage: 'Google Business Profile server error. Please try again.',
category: ErrorCategory::ServerError,
platformErrorCode: (string) $status,
rawResponse: $rawResponse,
);
}

return new static(
userMessage: $message !== '' ? $message : $rawResponse,
category: ErrorCategory::Unknown,
platformErrorCode: $reason !== '' ? $reason : (string) $status,
rawResponse: $rawResponse,
);
}

public function platform(): string
{
return 'google_business';
}

/**
* Whether this response confirms the account's own access_token is dead
* (not merely a transient or content-specific failure). Shared with
* ConnectionVerifier so both the publish and verify paths agree on what
* a dead Google Business Profile token looks like.
*/
public static function isConfirmedDeadToken(Response $response): bool
{
return $response->status() === 401
|| data_get($response->json(), 'error.status') === 'UNAUTHENTICATED';
}
}
3 changes: 3 additions & 0 deletions app/Http/Controllers/App/AnalyticsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use App\Http\Controllers\Controller;
use App\Models\SocialAccount;
use App\Services\Social\FacebookAnalytics;
use App\Services\Social\GoogleBusinessAnalytics;
use App\Services\Social\InstagramAnalytics;
use App\Services\Social\LinkedInPageAnalytics;
use App\Services\Social\PinterestAnalytics;
Expand Down Expand Up @@ -36,6 +37,7 @@ class AnalyticsController extends Controller
Platform::Pinterest,
Platform::YouTube,
Platform::Telegram,
Platform::GoogleBusiness,
];

public function index(Request $request): Response
Expand Down Expand Up @@ -82,6 +84,7 @@ public function show(Request $request, SocialAccount $account): JsonResponse
Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until),
Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until),
Platform::Telegram => app(TelegramAnalytics::class)->getMetrics($account),
Platform::GoogleBusiness => app(GoogleBusinessAnalytics::class)->getMetrics($account, $since, $until),
default => [],
};

Expand Down
Loading