Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
fa9de5a
feat: add Google Business Profile platform enum and config
Aug 11, 2026
0f5cdbf
feat: add GoogleBusinessPost content type
Aug 11, 2026
ddba0b1
feat: add Google Business Profile post meta validation rules
Aug 11, 2026
6687f83
feat: add GoogleBusinessPublishException error mapping
Aug 11, 2026
823ffc8
test: add missing INVALID_ARGUMENT error mapping test
Aug 11, 2026
89081de
feat: add GoogleBusinessPublisher for Local Post publishing
Aug 11, 2026
cf0423f
feat: add GoogleBusinessAnalytics location performance metrics
Aug 11, 2026
25c7279
fix: translate Google Business Profile metrics keys to all locales
Aug 11, 2026
0336dff
feat: verify and refresh Google Business Profile tokens
Aug 11, 2026
d3c80ff
feat: dispatch Google Business Profile posts through the publish job
Aug 11, 2026
2aede14
feat: add Google Business Profile OAuth connect flow
Aug 11, 2026
a37b280
feat: add Google Business Profile location picker page
Aug 11, 2026
4c3709e
feat: add Google Business Profile composer settings card
Aug 11, 2026
ea89890
fix: add ContentType.GoogleBusinessPost to frontend content-type enum
Aug 11, 2026
467cfb6
feat: wire Google Business Profile settings into the post composer
Aug 11, 2026
f18cfca
feat: add Google Business Profile post preview
Aug 11, 2026
8887b3f
fix: stop Google Business call_to_action.url rule blocking every plat…
Aug 11, 2026
f471a39
test: cover Google Business Profile meta across web, API, and MCP ent…
Aug 11, 2026
ce4016f
fix: remove redundant ReflectionMethod import in PostPlatformMetaRule…
Aug 11, 2026
4075623
fix: add GoogleBusiness case to MediaOptimizer image config match
Aug 11, 2026
69cab53
fix: register Google Business Profile in the frontend platform enum a…
Aug 11, 2026
ce92c83
fix: store the full accounts/*/locations/* name for the v4 Local Post…
Aug 11, 2026
74b4f0c
fix: wire Google Business Profile analytics into the analytics contro…
Aug 11, 2026
f31b840
fix: gate Google Business Profile publish on its required meta client…
Aug 11, 2026
83f3a43
fix: translate the Google Business Profile preview CTA labels
Aug 11, 2026
d84e137
fix: add the Google Business Profile arm to SocialAccount::profile_url
Aug 11, 2026
5251ffb
fix: send the event object for Google Business OFFER posts too
Aug 11, 2026
9264b35
fix: scope Google Business Profile validation errors to their own loc…
Aug 11, 2026
6c6b261
fix: forward the reconnect id through the Google Business OAuth callback
Aug 11, 2026
df3c56f
refactor: share the Google Business CTA action-type list between edit…
Aug 11, 2026
02040a5
chore: drop the unused GoogleBusinessPublisher::getLocations()
Aug 11, 2026
49c156e
fix: harden and de-duplicate the Google Business connect flow
Aug 11, 2026
a4de84e
fix: treat an explicitly null Google Business CTA action type as no CTA
Aug 11, 2026
b221b7a
perf: cache Google Business Profile location metrics like the sibling…
Aug 11, 2026
515d7da
refactor: share the Google Business dashboard URL builder between the…
Aug 11, 2026
6d9261c
feat: add the Google Business Profile analytics dashboard panel
Aug 11, 2026
06a21a3
fix: render the Google Business Profile tile and its default content …
Aug 11, 2026
3f1d5a4
fix: treat an explicitly null Google Business topic type as STANDARD
Aug 11, 2026
2743dc7
fix: reject blank Google Business event dates instead of publishing t…
Aug 11, 2026
09bf855
fix: clear the Google Business OAuth session on the unknown-location …
Aug 11, 2026
e17aa3e
docs: add Google Business Profile to the platform API documentation list
Aug 11, 2026
c60d0a6
fix: reject a blank Google Business event title instead of publishing it
Aug 11, 2026
7c02090
fix: guard the Google Business verify call against a missing location…
Aug 11, 2026
903f6b9
refactor: share the Google Business short-to-full location name conve…
Aug 11, 2026
a6dbd8e
test: cover the untested Google Business meta fields across the API a…
Aug 11, 2026
301f908
fix: replace the Google Business Profile placeholder icon with the re…
Aug 11, 2026
8e65ddc
docs: record durable rules for exhaustive Platform matches and per-pl…
Aug 11, 2026
2b06499
Merge branch 'main' into worktree-google-business-profile
Aug 16, 2026
4d66433
Merge branch 'main' into worktree-google-business-profile
Aug 16, 2026
86b7fe3
Merge branch 'main' into worktree-google-business-profile
Aug 31, 2026
04a13a2
Route the Google Business connect through connectIdentity
Aug 31, 2026
f3e1a81
Stop reporting a Google-rejected post as published
Sep 1, 2026
d0b29d0
Settle Google Business posts that are still in review
Sep 1, 2026
974902c
Report the Google metrics a business actually cares about
Sep 1, 2026
b0f3f4c
Send Google an image it will accept, and only offer locations that ca…
Sep 1, 2026
032a107
Show the search terms people used to find the business
Sep 1, 2026
0d6c8ca
Merge branch 'main' into worktree-google-business-profile
Sep 1, 2026
ddf4dd3
Merge branch 'main' into worktree-google-business-profile
Sep 19, 2026
e9ea1be
Keep Google Business working after the main merge.
Sep 19, 2026
285bd7f
Use notificationLabel for publish emails after main's display-name fa…
Sep 19, 2026
888dd52
Stop Google Business reviews from sticking and match the Local Posts …
Sep 21, 2026
30671d9
Keep Google Business images available during review and type the edit…
Sep 21, 2026
10bafdc
Type Google Business post state, topic, and CTA as enums.
Sep 21, 2026
36ca250
Drop deprecated Google Business CTAs and keep unspecified reviews pen…
Sep 21, 2026
511a36e
Localize publish notices and cap Google Business event titles at 58.
Sep 21, 2026
b786e1d
Simplify publish finalization, stuck-post recovery, and Google Busine…
Sep 21, 2026
b6ceb67
Simplify Google Business publishing by sharing one HTTP and paginatio…
Sep 21, 2026
ea2f015
Simplify Google Business OAuth by sharing one session bag and the par…
Sep 21, 2026
ac04e38
Simplify the Google Business preview by resolving topic type once.
Sep 21, 2026
62d9c36
Drop defensive Google Business name parsing; afterLast is enough.
Sep 21, 2026
eeebca5
Drop the exists check before pruning Google Business JPEG derivatives.
Sep 21, 2026
418c436
Simplify the Google Business publisher and cover the remaining publis…
Sep 21, 2026
3ff2ed7
Tell finished targets from closed ones so review does not settle the …
Sep 21, 2026
fe97c61
Settle posts only through FinalizePostPublication, including when the…
Sep 21, 2026
8c1b068
Pass the post into FinalizePostPublication instead of a dummy target.
Sep 21, 2026
f68e2c3
Drop the no-op RuntimeException import that aborts the Pest suite.
Sep 21, 2026
b90d6f2
Refresh Google Business reviews on a dead token and stop caching fail…
Sep 21, 2026
5e5f74b
Prevent a second publish notice after Google Business review and drop…
Sep 21, 2026
92a0606
Stop leaking Google Business JPEGs on timeout and workspace wipe, and…
Sep 21, 2026
221c456
Stop leaking Google Business JPEGs when a review target is switched off.
Sep 21, 2026
6637e5a
Settle Google Business reviews abandoned by disconnect or a disabled …
Sep 21, 2026
c61014d
Stop calling a switched-off destination a deactivated account.
Sep 21, 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
12 changes: 12 additions & 0 deletions .ai/rules/auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
paths:
- app/Http/Controllers/Auth/GoogleBusinessController.php
---

# Auth

## Clear google_business_oauth on every terminal connect path
google_business_oauth holds access and refresh tokens until the user picks a location. Forget it on every terminal select() exit, including the generic Exception catch, and in ConnectPopupException::render() alongside social_connect_workspace. Leaving it after uploadFromUrl / fetchLocationPhoto / connectIdentity failures leaks tokens into the session. On reconnect, keep the existing refresh_token when Google omits a new one.

## Forget google_business_oauth on every popupCallback
Override popupCallback to forgetOauthSession() so single-location success and every error path drop the token bag. Do not forget before the multi-location redirect to the picker — that is the only request that still needs it. select() finally and ConnectPopupException::render() also forget; a second forget is fine.
30 changes: 30 additions & 0 deletions .ai/rules/google-business.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
paths:
- 'app/Enums/GoogleBusiness/**'
- app/Jobs/PublishToSocialPlatform.php
- app/Jobs/ReconcileGoogleBusinessPost.php
- app/Console/Commands/ReconcileGoogleBusinessPosts.php
- app/Services/Social/GoogleBusinessPublisher.php
- app/Support/PostPlatformMetaRules.php
- app/Services/Social/GoogleBusinessAnalytics.php
---

# Google Business

## GBP Local Posts v4 values are enums
LocalPost.state, topicType, and callToAction.actionType live on App\Enums\GoogleBusiness\{LocalPostState,TopicType,CtaAction}. Compare cases / helpers (isPendingReview, isLive, requiresEvent, allowsCallToAction, requiresUrl) — never raw PROCESSING/SCHEDULED/STANDARD/OFFER strings. Do not ship Google-deprecated Local Post values: no GET_OFFER CTA, no ALERT/COVID_19 topic, no PRODUCT topic, no localPosts.reportInsights. Missing state defaults to Processing; Unspecified (LOCAL_POST_STATE_UNSPECIFIED) is pending review, not published. Missing topic defaults to Standard. Rejected targets have no retry — the user must duplicate the post. The Show/Edit publishing overlay must use isActivelyPublishing (in-flight platforms only); pending_review keeps post.status=publishing and would hide the page for up to 24h.

## GBP publish result uses tryFrom not fromApi
PublishToSocialPlatform::recordPublishResult must use LocalPostState::tryFrom, never fromApi. Other publishers omit `state`; fromApi(null) is Processing and would park LinkedIn/X/… in pending review. The GBP publisher always returns `$state->value` after fromApi, so the job only sees a known case.

## GBP event title is 58 characters, coupon is not
LocalPost event.title is capped at TopicType::TITLE_MAX_LENGTH (58) — Google returns Must be at most 58 characters even though the v4 schema page omits the limit. Do not reuse 58 on offer.coupon_code / terms; those have no published cap. Mirror the title cap in resources/js/types/google-business.ts as GOOGLE_BUSINESS_EVENT_TITLE_MAX. requiredMetaViolation() must reject a stored title over 58 — MCP PublishPostTool never re-runs rules(). Do not apply the cap to leftover titles on STANDARD.

## GBP reconcile refreshes on 401
ReconcileGoogleBusinessPost must call ConnectionVerifier::verify() after a TokenExpiredException from fetchLocalPost and retry the GET once. If refresh also fails, mark the SocialAccount token-expired, then deferOrGiveUp. Do not park a 401 in pending_review for 24h while a refresh would settle it. RecoverStuckPosts times the 24h ceiling from submitted_at only — never created_at.

## GBP analytics must not cache a failed fetch
GoogleBusinessAnalytics caches only a successful array. An HTTP failure or a missing location returns false and is not written to cache — a 500 must not blank the dashboard for an hour. Publish/verify/analytics require both location_id (v4) and location_name (v1) via GoogleBusinessResourceName::connectedLocation().

## Local Posts 401 after a live BI verify does not expire the account
retryAfterExpiredToken calls ConnectionVerifier::verify() then retries fetchRemote. markAsTokenExpired only when verify() itself throws TokenExpiredException. If BI verify succeeds and Local Posts still 401s, deferOrGiveUp without disconnecting — the token still works for the house verify. ReconcileGoogleBusinessPosts only dispatches enabled() pending_review rows. ReconcileGoogleBusinessPost::handle() giveUps immediately when socialAccount is null — disconnect nulls the FK and fetchRemote would TypeError until the 24h ceiling.
13 changes: 13 additions & 0 deletions .ai/rules/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# 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/Http/Controllers/Auth/GoogleBusinessController.php | .ai/rules/auth.md |
| app/Enums/GoogleBusiness/**, app/Jobs/PublishToSocialPlatform.php, app/Jobs/ReconcileGoogleBusinessPost.php, app/Console/Commands/ReconcileGoogleBusinessPosts.php, app/Services/Social/GoogleBusinessPublisher.php, app/Support/PostPlatformMetaRules.php, app/Services/Social/GoogleBusinessAnalytics.php | .ai/rules/google-business.md |
| app/Jobs/ReconcileGoogleBusinessPost.php, app/Console/Commands/RecoverStuckPosts.php | .ai/rules/jobs.md |
| app/Actions/Post/FinalizePostPublication.php, app/Jobs/PublishPost.php, app/Actions/Post/UpdatePost.php | .ai/rules/post.md |
| app/Enums/SocialAccount/Platform.php | .ai/rules/social-account.md |
| app/Services/Social/GoogleBusinessPublisher.php, app/Support/Social/GoogleBusinessDerivativeCleaner.php, app/Actions/Post/DeletePost.php, app/Actions/Post/UpdatePost.php, app/Support/Social/AbandonGoogleBusinessReview.php, app/Actions/Workspace/PurgeWorkspace.php, app/Http/Controllers/Auth/SocialController.php | .ai/rules/social.md |
| app/Support/PostPlatformMetaRules.php | .ai/rules/support.md |
10 changes: 10 additions & 0 deletions .ai/rules/jobs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
paths:
- app/Jobs/ReconcileGoogleBusinessPost.php
- app/Console/Commands/RecoverStuckPosts.php
---

# Jobs

## GBP reconcile must settle permanent API errors
ReconcileGoogleBusinessPost must not rethrow GoogleBusinessPublishException. Permanent categories (NOT_FOUND, PERMISSION_DENIED, INVALID_ARGUMENT, Unknown) reject the target immediately and call FinalizePostPublication. Only ServerError, RateLimit, and ConnectionException defer until REVIEW_CEILING_HOURS. Throwing leaves last_reconciled_at stale, the 5-minute sweep re-dispatches, RecoverStuckPosts treats PendingReview as still-active, and the post sticks forever. failed() is the safety net if the worker dies mid-review — it must deferOrGiveUp (respect the 24h ceiling), never giveUp immediately. Job $timeout must exceed HasSocialHttpClient's 120s HTTP timeout. RecoverStuckPosts may only fail PendingReview after the same 24h ceiling timed from submitted_at — never created_at (a scheduled draft can be days old before it enters review) and never the 1h publishing timeout. Reconcile must refresh-and-retry on TokenExpiredException the same way PublishToSocialPlatform does; if verify() itself dies, mark the social account token-expired before deferring. If Business Information verify succeeds and Local Posts still 401s, defer without markAsTokenExpired — the token is still valid for the house verify. A 401 must not sit in review for 24h while a refresh would have settled it. JPEG derivatives stay on disk while LocalPostState is Processing/Scheduled so Google can fetch sourceUrl; settle(), RecoverStuckPosts' 1h publishing timeout, a disabled GBP target, and the expired-review path prune them. Reconcile must giveUp immediately when socialAccount is null (disconnect nulls the FK and would TypeError in fetchRemote). When RecoverStuckPosts finishes a post (no still-active targets), call FinalizePostPublication so the owner is notified — do not mark the post Failed/Published by hand. Never compare raw PROCESSING/SCHEDULED/LIVE strings — use App\Enums\GoogleBusiness\LocalPostState.
20 changes: 20 additions & 0 deletions .ai/rules/post.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
paths:
- app/Actions/Post/FinalizePostPublication.php
- app/Jobs/PublishPost.php
- app/Actions/Post/UpdatePost.php
---

# Post

## FinalizePostPublication is the only post settler
handle() takes the Post, not a dummy PostPlatform. Every path that can finish the last enabled target must call it: PublishToSocialPlatform, ReconcileGoogleBusinessPost, RecoverStuckPosts, PublishPost::failed, and AbandonGoogleBusinessReview (disconnect / disable during pending_review). No enabled targets on a draft or scheduled post is a no-op — do not mark the post published. A Publishing post with no enabled targets is abandoned in-flight: mark it Failed so it does not sit non-editable forever. Do not mark the post Published / PartiallyPublished / Failed by hand outside Finalize.

## In-app publish notice uses owner locale
SendNotification title/body are stored already-resolved. Resolve them through lang/*/notifications.php (post_published / post_failed) with $owner->preferredLocale() — the worker locale is English. Mailables stay untranslated at dispatch; Mail::to($owner) applies HasLocalePreference. Do not hardcode English title/body here.

## Finalize is idempotent once the post is settled
handle() lockForUpdates the post and returns without notifying when status is already Published, PartiallyPublished, or Failed (Status::isSettled()). RecoverStuckPosts and ReconcileGoogleBusinessPost can both finish the last target at the 24h ceiling; the second call must not send a second email or toast. Dispatch SendNotification only after the transaction commits.

## Target disabled is not account inactive
Abandoning a GBP pending_review because the post destination was unchecked uses posts.errors.target_disabled. posts.errors.account_inactive stays for PublishToSocialPlatform when social_accounts.is_active is false. Do not reuse the account copy on a switched-off target.
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.
15 changes: 15 additions & 0 deletions .ai/rules/social.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
paths:
- app/Services/Social/GoogleBusinessPublisher.php
- app/Support/Social/GoogleBusinessDerivativeCleaner.php
- app/Actions/Post/DeletePost.php
- app/Actions/Post/UpdatePost.php
- app/Support/Social/AbandonGoogleBusinessReview.php
- app/Actions/Workspace/PurgeWorkspace.php
- app/Http/Controllers/Auth/SocialController.php
---

# Social

## GBP JPEG must outlive PROCESSING
Google fetches Local Post sourceUrl after create while LocalPostState is Processing/Scheduled/Unspecified. Keep the JPEG derivative on disk until reconcile settle() or RecoverStuckPosts fails the target. Deleting in publish() finally races PHOTO_FETCH_FAILED. Live/Rejected on the create response may prune immediately. Path is deterministic: google-business-derivatives/{postPlatformId}.jpg. Wire values live on App\Enums\GoogleBusiness\LocalPostState — never compare raw PROCESSING/SCHEDULED strings. RecoverStuckPosts must prune the JPEG on the 1h Publishing/Pending/Retrying timeout (the worker can die after writing the file and before PendingReview), on a disabled GBP target (reconcile and the 24h ceiling skip `enabled=false`), and again on the 24h review ceiling. UpdatePost mass-updates `enabled=false` (observers never fire): abandon any GBP still in pending_review that is not in the kept set, then prune leftover JPEGs after commit. Disconnect must abandon pending_review rows before the account delete (nullOnDelete would otherwise leave reconcile TypeErroring until the 24h ceiling). DeletePost and PurgeWorkspace must prune every Google Business PostPlatform JPEG before the row disappears — a user delete during pending_review or a workspace wipe would otherwise leak the file, and a DB cascade on post_platforms does not fire Eloquent observers.
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 @@ -158,6 +158,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 @@ -283,6 +289,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 @@ -575,6 +575,7 @@ Browser tests live in `tests/Browser` and run on `pestphp/pest-plugin-browser` d
- **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
7 changes: 7 additions & 0 deletions app/Actions/Post/DeletePost.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@

namespace App\Actions\Post;

use App\Enums\SocialAccount\Platform;
use App\Events\PostDeleted;
use App\Models\Post;
use App\Support\Social\GoogleBusinessDerivativeCleaner;

class DeletePost
{
public static function execute(Post $post): void
{
$post->postPlatforms()
->where('platform', Platform::GoogleBusiness)
->pluck('id')
->each(fn (string $id) => app(GoogleBusinessDerivativeCleaner::class)->cleanup($id));

$postId = $post->id;
$workspaceId = $post->workspace_id;

Expand Down
117 changes: 117 additions & 0 deletions app/Actions/Post/FinalizePostPublication.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

declare(strict_types=1);

namespace App\Actions\Post;

use App\Enums\Notification\Channel;
use App\Enums\Notification\Type;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Jobs\SendNotification;
use App\Mail\PostPublished;
use App\Mail\PostPublishFailed;
use App\Models\Post;
use App\Models\PostPlatform;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

/**
* Settles a post once every enabled target has reached a terminal state, and
* notifies the owner once. Shared by PublishToSocialPlatform, PublishPost::failed,
* RecoverStuckPosts, and ReconcileGoogleBusinessPost.
*/
class FinalizePostPublication
{
public function handle(Post $post): void
{
/** @var array{post: Post, successful: bool, platforms: Collection<int, PostPlatform>}|null $outcome */
$outcome = DB::transaction(function () use ($post): ?array {
$post = Post::query()
->with(['workspace.owner', 'postPlatforms.socialAccount'])
->whereKey($post->id)
->lockForUpdate()
->first();

if (! $post instanceof Post || $post->status->isSettled()) {
return null;
}

$targets = $post->postPlatforms->where('enabled', true);

if ($targets->isEmpty()) {
if ($post->status !== PostStatus::Publishing) {
return null;
}

$post->markAsFailed();

return [
'post' => $post,
'successful' => false,
'platforms' => $post->postPlatforms->filter(
fn (PostPlatform $target): bool => $target->status->isFinished(),
),
];
}

$finished = $targets->filter(fn (PostPlatform $target): bool => $target->status->isFinished());
$published = $finished->where('status', PostPlatformStatus::Published);
$failed = $finished->reject(fn (PostPlatform $target): bool => $target->status === PostPlatformStatus::Published);

if ($finished->count() < $targets->count()) {
return null;
}

$successful = $failed->isEmpty();

if ($successful) {
$post->markAsPublished();
} elseif ($published->isNotEmpty()) {
$post->markAsPartiallyPublished();
} else {
$post->markAsFailed();
}

return [
'post' => $post,
'successful' => $successful,
'platforms' => $successful ? $published : $failed,
];
});

if ($outcome === null) {
return;
}

$this->notify($outcome['post'], $outcome['successful'], $outcome['platforms']);
}

/**
* @param Collection<int, PostPlatform> $platforms
*/
private function notify(Post $post, bool $successful, Collection $platforms): void
{
$owner = $post->workspace->owner;

if (! $owner) {
return;
}

$type = $successful ? Type::PostPublished : Type::PostFailed;
$locale = $owner->preferredLocale();

SendNotification::dispatch(
user: $owner,
workspaceId: $post->workspace_id,
type: $type,
channel: Channel::Both,
title: __("notifications.{$type->value}.title", [], $locale),
body: __("notifications.{$type->value}.body", [
'platforms' => $platforms->map->notificationLabel()->implode(', '),
], $locale),
data: ['post_id' => $post->id],
mailable: $successful ? new PostPublished($post) : new PostPublishFailed($post),
);
}
}
Loading
Loading