Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ GOOGLE_AUTH_ENABLED=false
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CLIENT_REDIRECT="${APP_URL}/accounts/youtube/callback"

# Google Business Profile (https://developers.google.com/my-business)
GOOGLE_BUSINESS_PROFILE_CLIENT_ID=
GOOGLE_BUSINESS_PROFILE_CLIENT_SECRET=
GOOGLE_BUSINESS_PROFILE_CLIENT_REDIRECT="${APP_URL}/accounts/google-business-profile/callback"
GOOGLE_BUSINESS_PROFILE_ENABLED=true
GOOGLE_AUTH_CALLBACK="${APP_URL}/auth/google/callback"

# GitHub (https://github.com/settings/developers)
Expand Down
27 changes: 26 additions & 1 deletion app/Actions/Post/CreatePost.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@

use App\Enums\Post\CreatedVia;
use App\Enums\Post\Status as PostStatus;
use App\Enums\SocialAccount\Platform;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

class CreatePost
{
Expand All @@ -35,7 +37,7 @@ class CreatePost
* date?: ?string,
* scheduled_at?: ?string,
* created_via?: ?CreatedVia,
* platforms?: array<int, array{social_account_id: string, content_type?: string, meta?: array<string, mixed>}>,
* platforms?: array<int, array{social_account_id: string, google_business_profile_location_id?: string, content_type?: string, meta?: array<string, mixed>}>,
* label_ids?: array<int, string>
* } $data
*/
Expand All @@ -62,6 +64,27 @@ public static function execute(Workspace $workspace, User $user, array $data): P
}

$updates = ['enabled' => true];
$googleBusinessProfileLocationId = data_get($platformData, 'google_business_profile_location_id');
$account = $workspace->socialAccounts()->find($accountId);

if ($account?->platform === Platform::GoogleBusinessProfile) {
if (! $googleBusinessProfileLocationId) {
throw ValidationException::withMessages([
'platforms' => 'Choose a specific Google Business Profile location.',
]);
}

$validLocation = $account->googleBusinessProfileLocations()
->where('is_selected', true)
->whereKey($googleBusinessProfileLocationId)
->exists();

if (! $validLocation) {
throw ValidationException::withMessages([
'platforms' => 'Choose a currently connected Google Business Profile location.',
]);
}
}

if ($contentType = data_get($platformData, 'content_type')) {
$updates['content_type'] = $contentType;
Expand All @@ -71,6 +94,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P
if (is_array($meta) && $meta !== []) {
$existing = $post->postPlatforms()
->where('social_account_id', $accountId)
->when($googleBusinessProfileLocationId, fn ($query) => $query->where('google_business_profile_location_id', $googleBusinessProfileLocationId))
->first();

if ($existing) {
Expand All @@ -83,6 +107,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P

$post->postPlatforms()
->where('social_account_id', $accountId)
->when($googleBusinessProfileLocationId, fn ($query) => $query->where('google_business_profile_location_id', $googleBusinessProfileLocationId))
->update($updates);
}

Expand Down
21 changes: 18 additions & 3 deletions app/Actions/Post/DeletePost.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,30 @@

use App\Events\PostDeleted;
use App\Models\Post;
use App\Support\PostStatusRules;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

class DeletePost
{
public static function execute(Post $post): void
{
$postId = $post->id;
$workspaceId = $post->workspace_id;
[$postId, $workspaceId] = DB::transaction(function () use ($post): array {
$fresh = Post::query()->lockForUpdate()->findOrFail($post->getKey());

$post->delete();
if (PostStatusRules::blocksDeletion($fresh)) {
throw ValidationException::withMessages([
'post' => PostStatusRules::deleteBlockedMessage(),
]);
}

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

$fresh->delete();

return [$postId, $workspaceId];
});

PostDeleted::dispatch($postId, $workspaceId);
}
Expand Down
27 changes: 22 additions & 5 deletions app/Actions/Post/DuplicatePost.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use App\Enums\Post\CreatedVia;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Models\Post;
use App\Models\User;
Expand Down Expand Up @@ -36,26 +37,42 @@ public static function execute(Post $original, User $user): Post
]);

$platforms = $original->postPlatforms()
->with('googleBusinessProfileLocation')
->whereHas('socialAccount')
->get();

foreach ($platforms as $platform) {
$locationDisconnected = $platform->google_business_profile_location_id
&& ! $platform->googleBusinessProfileLocation?->is_selected;
$contentType = $platform->content_type->isAuthorable()
? $platform->content_type
: ContentType::defaultFor($platform->platform);
$meta = $platform->meta;

if (! $platform->content_type->isAuthorable()) {
unset($meta['alert_type']);
}

$copy->postPlatforms()->create([
'social_account_id' => $platform->social_account_id,
'google_business_profile_location_id' => $platform->google_business_profile_location_id,
'platform' => $platform->platform,
'platform_name' => $platform->platform_name,
'platform_username' => $platform->platform_username,
'platform_avatar' => $platform->getRawOriginal('platform_avatar'),
'content_type' => $platform->content_type,
'enabled' => $platform->enabled,
'meta' => $platform->meta,
'content_type' => $contentType,
'enabled' => $platform->enabled && ! $locationDisconnected,
'meta' => $meta,
// Always reset platform-level status — never carry
// published/failed/publishing into the new draft.
'status' => PostPlatformStatus::Pending,
'platform_post_id' => null,
'platform_url' => null,
'error_message' => null,
'error_context' => null,
'error_message' => $locationDisconnected ? __('posts.errors.gbp_location_disconnected') : null,
'error_context' => $locationDisconnected ? [
'category' => 'connection_action_required',
'reason' => 'gbp_location_disconnected',
] : null,
'published_at' => null,
]);
}
Expand Down
35 changes: 33 additions & 2 deletions app/Actions/Post/SyncPostPlatforms.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use App\Enums\PostPlatform\ContentType;
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Enums\SocialAccount\Platform;
use App\Models\Post;

class SyncPostPlatforms
Expand All @@ -20,14 +21,44 @@ public static function execute(Post $post): void
{
$workspace = $post->workspace;

$existingAccountIds = $post->postPlatforms()->pluck('social_account_id')->filter();
$existingAccountIds = $post->postPlatforms()
->whereNull('google_business_profile_location_id')
->pluck('social_account_id')
->filter();

$existingGoogleLocationIds = $post->postPlatforms()
->whereNotNull('google_business_profile_location_id')
->pluck('google_business_profile_location_id')
->filter();

$missingAccounts = $workspace->socialAccounts()
->active()
->whereNotIn('id', $existingAccountIds)
->with(['googleBusinessProfileLocations' => fn ($query) => $query->where('is_selected', true)])
->get();

foreach ($missingAccounts as $account) {
if ($account->platform === Platform::GoogleBusinessProfile) {
foreach ($account->googleBusinessProfileLocations->whereNotIn('id', $existingGoogleLocationIds) as $location) {
$post->postPlatforms()->create([
'social_account_id' => $account->id,
'google_business_profile_location_id' => $location->id,
'platform' => $account->platform->value,
'platform_name' => $location->title,
'platform_username' => $location->store_code,
'platform_avatar' => $account->getRawOriginal('avatar_url'),
'content_type' => ContentType::defaultFor($account->platform),
'status' => PostPlatformStatus::Pending,
'enabled' => false,
]);
}

continue;
}

if ($existingAccountIds->contains($account->id)) {
continue;
}

$post->postPlatforms()->create([
'social_account_id' => $account->id,
'platform' => $account->platform->value,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace App\Actions\SocialAccount;

use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Models\Post;
use App\Models\PostPlatform;
use Illuminate\Support\Collection;

class ManageGoogleBusinessProfileLocationTargets
{
private const REASON = 'gbp_location_disconnected';

/** @param Collection<int, string>|array<int, string> $locationIds */
public function disable(Collection|array $locationIds): int
{
$ids = collect($locationIds)->values();

if ($ids->isEmpty()) {
return 0;
}

$targets = PostPlatform::query()
->whereIn('google_business_profile_location_id', $ids)
->where('status', PostPlatformStatus::Pending)
->where('enabled', true)
->whereHas('post', fn ($query) => $query->whereIn('status', [PostStatus::Draft, PostStatus::Scheduled]))
->lockForUpdate()
->get();

$affectedPostIds = $targets->pluck('post_id')->unique();

foreach ($targets as $target) {
$target->update([
'enabled' => false,
'error_message' => __('posts.errors.gbp_location_disconnected'),
'error_context' => [
...(is_array($target->error_context) ? $target->error_context : []),
'category' => 'connection_action_required',
'reason' => self::REASON,
'disconnected_at' => now()->toIso8601String(),
],
]);
}

// A scheduled post is one publish unit. If any destination is removed,
// stop the whole schedule so it cannot partially publish elsewhere.
Post::query()
->whereIn('id', $affectedPostIds)
->where('status', PostStatus::Scheduled)
->update(['status' => PostStatus::Draft, 'scheduled_at' => null]);

return $targets->count();
}

/** @param Collection<int, string>|array<int, string> $locationIds */
public function restore(Collection|array $locationIds): int
{
$ids = collect($locationIds)->values();

if ($ids->isEmpty()) {
return 0;
}

$targets = PostPlatform::query()
->whereIn('google_business_profile_location_id', $ids)
->where('status', PostPlatformStatus::Pending)
->where('enabled', false)
->whereHas('post', fn ($query) => $query->where('status', PostStatus::Draft))
->lockForUpdate()
->get()
->filter(fn (PostPlatform $target): bool => data_get($target->error_context, 'reason') === self::REASON);

foreach ($targets as $target) {
$target->update([
'enabled' => true,
'error_message' => null,
'error_context' => null,
]);
}

return $targets->count();
}
}
32 changes: 32 additions & 0 deletions app/Console/Commands/ReconcileGoogleBusinessProfilePosts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Enums\PostPlatform\Status;
use App\Enums\SocialAccount\Platform;
use App\Jobs\ReconcileGoogleBusinessProfilePost;
use App\Models\PostPlatform;
use Illuminate\Console\Command;

class ReconcileGoogleBusinessProfilePosts extends Command
{
protected $signature = 'google-business-profile:reconcile-posts';

protected $description = 'Reconcile submitted Google Business Profile posts with their live Google state';

public function handle(): void
{
PostPlatform::query()
->where('platform', Platform::GoogleBusinessProfile)
->whereIn('status', [Status::Submitted, Status::PendingReview])
->whereNotNull('platform_post_id')
->oldest('last_reconciled_at')
->chunkById(100, function ($postPlatforms): void {
foreach ($postPlatforms as $postPlatform) {
ReconcileGoogleBusinessProfilePost::dispatch($postPlatform);
}
});
}
}
12 changes: 10 additions & 2 deletions app/Console/Commands/RecoverStuckPosts.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,13 @@ public function handle(): void
// fresh updated_at — do not finalize the post while that work is still live.
$stillActive = $post->postPlatforms()
->enabled()
->whereIn('status', [PlatformStatus::Publishing, PlatformStatus::Pending, PlatformStatus::Retrying])
->whereIn('status', [
PlatformStatus::Publishing,
PlatformStatus::Pending,
PlatformStatus::Retrying,
PlatformStatus::Submitted,
PlatformStatus::PendingReview,
])
->exists();

if ($stillActive) {
Expand All @@ -70,7 +76,9 @@ public function handle(): void
$total = $enabledPlatforms->count();
$publishedCount = $enabledPlatforms->where('status', PlatformStatus::Published)->count();

if ($publishedCount === $total) {
if ($total === 0) {
$post->markAsFailed();
} elseif ($publishedCount === $total) {
$post->markAsPublished();
} elseif ($publishedCount > 0) {
$post->markAsPartiallyPublished();
Expand Down
Loading