diff --git a/.gitignore b/.gitignore index e61ad860..f65483e1 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ yarn-error.log /.vscode /.zed /docs/ +/.superpowers/ diff --git a/app/Actions/Post/SyncPostPlatforms.php b/app/Actions/Post/SyncPostPlatforms.php index 20fa7780..b0709fdb 100644 --- a/app/Actions/Post/SyncPostPlatforms.php +++ b/app/Actions/Post/SyncPostPlatforms.php @@ -31,7 +31,7 @@ public static function execute(Post $post): void $post->postPlatforms()->create([ 'social_account_id' => $account->id, 'platform' => $account->platform->value, - 'platform_name' => $account->display_name, + 'platform_name' => $account->accountDisplayName(), 'platform_username' => $account->username, 'platform_avatar' => $account->getRawOriginal('avatar_url'), 'content_type' => ContentType::defaultFor($account->platform), diff --git a/app/Console/Commands/CheckUpcomingPostConnections.php b/app/Console/Commands/CheckUpcomingPostConnections.php new file mode 100644 index 00000000..2a6bf450 --- /dev/null +++ b/app/Console/Commands/CheckUpcomingPostConnections.php @@ -0,0 +1,45 @@ +where('post_platforms.status', PostPlatformStatus::Pending) + ->enabled() + // Mirrors VerifyUpcomingPostConnections::atRiskPostPlatforms() — + // a paused account can't be the reason to dispatch a job for its + // workspace, since the job itself will skip it too. whereHas() + // already excludes a null social_account_id (nothing to join to). + ->whereHas('socialAccount', fn ($query) => $query->where('is_active', true)) + ->where(function ($query) { + $query->whereNull('post_platforms.connection_warning_sent_at') + ->orWhere('post_platforms.connection_warning_sent_at', '<', now()->subDay()); + }) + ->join('posts', 'posts.id', '=', 'post_platforms.post_id') + ->where('posts.status', PostStatus::Scheduled) + ->whereBetween('posts.scheduled_at', [now(), now()->addHour()]) + ->distinct() + ->pluck('posts.workspace_id'); + + foreach ($workspaceIds as $workspaceId) { + VerifyUpcomingPostConnections::dispatch($workspaceId); + } + + $this->info("Dispatched {$workspaceIds->count()} upcoming-post connection checks."); + } +} diff --git a/app/Console/Commands/RecoverStuckPosts.php b/app/Console/Commands/RecoverStuckPosts.php index a4cee4b4..617a02ac 100644 --- a/app/Console/Commands/RecoverStuckPosts.php +++ b/app/Console/Commands/RecoverStuckPosts.php @@ -24,7 +24,7 @@ public function handle(): void ->where('updated_at', '<=', now()->subHour()) ->each(function (Post $post) use (&$count) { $post->postPlatforms() - ->where('enabled', true) + ->enabled() ->whereIn('status', [PlatformStatus::Publishing, PlatformStatus::Pending, PlatformStatus::Retrying]) ->where('updated_at', '<=', now()->subHour()) ->update([ @@ -39,7 +39,7 @@ public function handle(): void // Delayed platform-unavailable retries keep the platform Retrying with a // fresh updated_at — do not finalize the post while that work is still live. $stillActive = $post->postPlatforms() - ->where('enabled', true) + ->enabled() ->whereIn('status', [PlatformStatus::Publishing, PlatformStatus::Pending, PlatformStatus::Retrying]) ->exists(); @@ -47,7 +47,7 @@ public function handle(): void return; } - $enabledPlatforms = $post->postPlatforms()->where('enabled', true)->get(); + $enabledPlatforms = $post->postPlatforms()->enabled()->get(); $total = $enabledPlatforms->count(); $publishedCount = $enabledPlatforms->where('status', PlatformStatus::Published)->count(); diff --git a/app/Enums/Notification/Type.php b/app/Enums/Notification/Type.php index 04263b50..f4dc9663 100644 --- a/app/Enums/Notification/Type.php +++ b/app/Enums/Notification/Type.php @@ -11,6 +11,7 @@ enum Type: string case PostPartiallyPublished = 'post_partially_published'; case PostReady = 'post_ready'; case AccountDisconnected = 'account_disconnected'; + case PostAtRisk = 'post_at_risk'; case InviteReceived = 'invite_received'; case MemberJoined = 'member_joined'; case MemberRemoved = 'member_removed'; diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 1849c36d..e665ed45 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -310,6 +310,25 @@ public function extendsAccessTokenOnRefresh(): bool }; } + /** + * Whether ConnectionVerifier has a real per-account token refresh flow + * for this platform. Facebook/InstagramFacebook use Page tokens and + * Mastodon's tokens don't expire (see defaultTokenTtlSeconds()); Telegram + * and Discord authenticate with one bot token shared across every + * connected account of that platform, with no per-account credential to + * refresh at all. For these, a rejected verify call can't be retried + * after a refresh — there's nothing to refresh. + */ + 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, + default => false, + }; + } + /** * The `platform` column values of the platforms that refresh by extending * their access token in place (Instagram and Threads — see diff --git a/app/Exceptions/Social/BlueskyPublishException.php b/app/Exceptions/Social/BlueskyPublishException.php index b6fc6b1a..3dc6b9a6 100644 --- a/app/Exceptions/Social/BlueskyPublishException.php +++ b/app/Exceptions/Social/BlueskyPublishException.php @@ -19,7 +19,7 @@ public static function fromApiResponse(mixed $response): static $error = data_get($body, 'error', ''); $errorMessage = data_get($body, 'message', 'An unknown Bluesky error occurred.'); - if (in_array($error, ['ExpiredToken', 'InvalidToken'], true)) { + if (self::isConfirmedDeadToken($response)) { throw new TokenExpiredException( message: $errorMessage, platformErrorCode: $error, @@ -74,4 +74,17 @@ public function platform(): string { return 'bluesky'; } + + /** + * Whether this response confirms the account's own session 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 Bluesky session looks like. + */ + public static function isConfirmedDeadToken(Response $response): bool + { + $error = data_get($response->json(), 'error', ''); + + return in_array($error, ['ExpiredToken', 'InvalidToken'], true); + } } diff --git a/app/Exceptions/Social/DiscordPublishException.php b/app/Exceptions/Social/DiscordPublishException.php index 3aaa40cf..4de3bcb8 100644 --- a/app/Exceptions/Social/DiscordPublishException.php +++ b/app/Exceptions/Social/DiscordPublishException.php @@ -86,4 +86,26 @@ public function platform(): string { return 'discord'; } + + /** + * Whether this response confirms the bot lost access to THIS specific + * guild (kicked, missing access, or the guild is gone) — used by + * ConnectionVerifier against getGuild's response. + * + * Deliberately NOT the same check fromApiResponse() uses above, and not + * called from it: fromApiResponse() classifies channel-message responses + * (channel-send scope, where a 403 there stays a Permission-category + * publish failure rather than disconnecting the account — the bot could + * still be a guild member with access to other channels), while this + * classifies getGuild responses (guild-membership scope, where the same + * 403/404 unambiguously means the bot is out of this guild entirely). + * 401 is excluded from both: Discord auth is one bot token shared across + * every connected account, so a 401 means that shared token is + * misconfigured (an operator problem), never evidence that this specific + * guild connection is dead. + */ + public static function isConfirmedDeadGuild(Response $response): bool + { + return in_array($response->status(), [403, 404], true); + } } diff --git a/app/Exceptions/Social/LinkedInPublishException.php b/app/Exceptions/Social/LinkedInPublishException.php index aad4a088..2b881242 100644 --- a/app/Exceptions/Social/LinkedInPublishException.php +++ b/app/Exceptions/Social/LinkedInPublishException.php @@ -18,7 +18,7 @@ public static function fromApiResponse(mixed $response): static $errorMessage = data_get($body, 'message', $rawResponse); - if ($statusCode === 401) { + if (self::isConfirmedDeadToken($response)) { throw new TokenExpiredException( message: $errorMessage ?? 'Access token has expired or been revoked', platformErrorCode: (string) $statusCode, @@ -61,4 +61,15 @@ public function platform(): string { return 'linkedin'; } + + /** + * 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 LinkedIn/LinkedIn Page token looks like. + */ + public static function isConfirmedDeadToken(Response $response): bool + { + return $response->status() === 401; + } } diff --git a/app/Exceptions/Social/MastodonPublishException.php b/app/Exceptions/Social/MastodonPublishException.php index 782f8ab4..b06710b1 100644 --- a/app/Exceptions/Social/MastodonPublishException.php +++ b/app/Exceptions/Social/MastodonPublishException.php @@ -18,7 +18,7 @@ public static function fromApiResponse(mixed $response): static $errorMessage = data_get($body, 'error', 'An unknown Mastodon error occurred.'); - if ($status === 401) { + if (self::isConfirmedDeadToken($response)) { throw new TokenExpiredException( message: $errorMessage, platformErrorCode: (string) $status, @@ -91,4 +91,23 @@ public function platform(): string { return 'mastodon'; } + + /** + * 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 Mastodon token looks like. + * + * 403 is deliberately NOT included here: on the write-scoped /statuses + * endpoint a 403 can mean the app only has read scope, which doesn't + * prove the token itself is dead (see 'mastodon publisher throws + * permission exception on forbidden'). ConnectionVerifier adds its own + * 403 check on top of this one, because verify_credentials is the + * lowest-privilege read endpoint — a 403 there means the token has no + * access at all, a stronger and different signal than a write-scope 403. + */ + public static function isConfirmedDeadToken(Response $response): bool + { + return $response->status() === 401; + } } diff --git a/app/Exceptions/Social/PinterestPublishException.php b/app/Exceptions/Social/PinterestPublishException.php index 58ad92c2..ca13f92a 100644 --- a/app/Exceptions/Social/PinterestPublishException.php +++ b/app/Exceptions/Social/PinterestPublishException.php @@ -16,7 +16,7 @@ public static function fromApiResponse(mixed $response): static $body = $response->json(); $rawResponse = $response->body(); - if ($status === 401) { + if (self::isConfirmedDeadToken($response)) { throw new TokenExpiredException( message: data_get($body, 'message', 'Access token has expired or been revoked'), platformErrorCode: (string) $status, @@ -90,4 +90,15 @@ public function platform(): string { return 'pinterest'; } + + /** + * 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 Pinterest token looks like. + */ + public static function isConfirmedDeadToken(Response $response): bool + { + return $response->status() === 401; + } } diff --git a/app/Exceptions/Social/TelegramPublishException.php b/app/Exceptions/Social/TelegramPublishException.php index 56675239..1cf837b7 100644 --- a/app/Exceptions/Social/TelegramPublishException.php +++ b/app/Exceptions/Social/TelegramPublishException.php @@ -65,4 +65,25 @@ public function platform(): string { return 'telegram'; } + + /** + * Whether this response confirms the bot lost access to THIS specific + * chat (kicked, blocked, or the chat was deleted) — used by + * ConnectionVerifier against getChat's response. + * + * Deliberately NOT the same check fromApiResponse() uses above, and not + * called from it: fromApiResponse() classifies sendMessage/sendPhoto + * responses (message-send scope, where a 403 there stays a + * Permission-category publish failure rather than disconnecting the + * account — the bot could still reach other chats fine), while this + * classifies getChat responses (chat-read scope, where the same 400/403 + * unambiguously means this one chat is gone). 401 is excluded from both: + * Telegram auth is one bot token shared across every connected account, + * so a 401 means that shared token is misconfigured (an operator + * problem), never evidence that this specific chat connection is dead. + */ + public static function isConfirmedDeadChat(Response $response): bool + { + return in_array($response->status(), [400, 403], true); + } } diff --git a/app/Exceptions/Social/TikTokPublishException.php b/app/Exceptions/Social/TikTokPublishException.php index 5d8cf1c6..fa87e12e 100644 --- a/app/Exceptions/Social/TikTokPublishException.php +++ b/app/Exceptions/Social/TikTokPublishException.php @@ -18,7 +18,7 @@ public static function fromApiResponse(mixed $response): static $errorCode = data_get($body, 'error.code'); $errorMessage = data_get($body, 'error.message', 'An unknown TikTok error occurred.'); - if ($errorCode === 'access_token_invalid') { + if (self::isConfirmedDeadToken($response)) { throw new TokenExpiredException( message: $errorMessage, platformErrorCode: $errorCode, @@ -79,4 +79,29 @@ public function platform(): string { return 'tiktok'; } + + /** + * 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 TikTok token looks like. 10001/10002 are the numeric forms of + * the same access_token_invalid/expired conditions TikTok also reports. + * + * A bare HTTP 401 is deliberately NOT treated as confirmed-dead here: + * TikTok also returns 401 for scope_not_authorized/scope_permission_missed + * (missing video.publish grant — see + * https://developers.tiktok.com/doc/content-posting-api-reference-direct-post), + * which is a scope gap, not a dead token, and must stay a Permission-category + * publish failure (see the match arms above) rather than disconnecting the + * account. ConnectionVerifier adds its own bare-401 check on top of this + * one, because /v2/user/info/ only needs the always-granted user.info.basic + * scope — a 401 there can't be a scope gap, so it's an unambiguous signal + * the token itself is dead. + */ + public static function isConfirmedDeadToken(Response $response): bool + { + $errorCode = data_get($response->json(), 'error.code'); + + return in_array($errorCode, ['access_token_invalid', 'access_token_expired', 10001, 10002]); + } } diff --git a/app/Exceptions/Social/XPublishException.php b/app/Exceptions/Social/XPublishException.php index d08a3f6a..aed3abd9 100644 --- a/app/Exceptions/Social/XPublishException.php +++ b/app/Exceptions/Social/XPublishException.php @@ -22,7 +22,7 @@ public static function fromApiResponse(mixed $response): static $typeSuffix = $type !== '' ? basename((string) $type) : ''; - if ($statusCode === 401 || str_contains((string) $type, 'unsupported-authentication')) { + if (self::isConfirmedDeadToken($response)) { throw new TokenExpiredException( message: $detail ?: 'Access token has expired or been revoked', platformErrorCode: $typeSuffix ?: (string) $statusCode, @@ -113,4 +113,17 @@ public function platform(): string { return 'x'; } + + /** + * 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 X token looks like. + */ + public static function isConfirmedDeadToken(Response $response): bool + { + $type = (string) data_get($response->json(), 'type', ''); + + return $response->status() === 401 || str_contains($type, 'unsupported-authentication'); + } } diff --git a/app/Exceptions/Social/YouTubePublishException.php b/app/Exceptions/Social/YouTubePublishException.php index 45acd79f..8fec88a3 100644 --- a/app/Exceptions/Social/YouTubePublishException.php +++ b/app/Exceptions/Social/YouTubePublishException.php @@ -17,10 +17,18 @@ public static function fromApiResponse(mixed $response): static $rawResponse = $response->body(); $reason = data_get($body, 'error.errors.0.reason'); + $fallbackMessage = data_get($body, 'error.message', 'An unknown YouTube error occurred.'); + + if (self::isConfirmedDeadToken($response)) { + throw new TokenExpiredException( + message: $fallbackMessage, + platformErrorCode: $reason, + ); + } [$message, $category] = self::mapReasonToMessageAndCategory( reason: $reason, - fallbackMessage: data_get($body, 'error.message', 'An unknown YouTube error occurred.'), + fallbackMessage: $fallbackMessage, ); return new static( @@ -62,6 +70,17 @@ public function platform(): string return 'youtube'; } + /** + * 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 YouTube token looks like. + */ + public static function isConfirmedDeadToken(Response $response): bool + { + return $response->status() === 401; + } + /** * @return array{string, ErrorCategory} */ diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php index 919486bf..cac42f7c 100644 --- a/app/Http/Controllers/App/AnalyticsController.php +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -51,8 +51,8 @@ public function index(Request $request): Response ->map(fn (SocialAccount $account) => [ 'id' => $account->id, 'platform' => $account->platform->value, - 'display_name' => $account->display_name, 'username' => $account->username, + 'display_label' => $account->display_label, 'avatar_url' => $account->avatar_url, ]); diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index c1ebc395..61981c89 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -46,7 +46,7 @@ public function index(Request $request, ?string $status = null): Response|Redire $this->authorize('view', $workspace); $query = $workspace->posts() - ->with(['postPlatforms' => fn ($query) => $query->where('enabled', true)->with('socialAccount'), 'user', 'labels']); + ->with(['postPlatforms' => fn ($query) => $query->enabled()->with('socialAccount'), 'user', 'labels']); if ($status) { $query = match ($status) { @@ -123,7 +123,7 @@ public function calendar(Request $request): Response|RedirectResponse }; $posts = $workspace->posts() - ->with(['postPlatforms' => fn ($query) => $query->where('enabled', true)->with('socialAccount')]) + ->with(['postPlatforms' => fn ($query) => $query->enabled()->with('socialAccount')]) ->whereBetween('scheduled_at', [$rangeStart->copy()->utc(), $rangeEnd->copy()->utc()]) ->orderBy('scheduled_at') ->get() diff --git a/app/Http/Resources/App/SocialAccountResource.php b/app/Http/Resources/App/SocialAccountResource.php index e4bef4e5..ee476b44 100644 --- a/app/Http/Resources/App/SocialAccountResource.php +++ b/app/Http/Resources/App/SocialAccountResource.php @@ -21,6 +21,8 @@ public function toArray(Request $request): array 'platform_user_id' => $this->platform_user_id, 'username' => $this->username, 'display_name' => $this->display_name, + 'display_label' => $this->display_label, + 'handle_label' => $this->handle_label, 'avatar_url' => $this->avatar_url, 'profile_url' => $this->profile_url, 'status' => $this->status, diff --git a/app/Jobs/PublishPost.php b/app/Jobs/PublishPost.php index 792a5368..d2ad9a18 100644 --- a/app/Jobs/PublishPost.php +++ b/app/Jobs/PublishPost.php @@ -21,7 +21,7 @@ public function handle(): void { $this->post->markAsPublishing(); - foreach ($this->post->postPlatforms()->where('enabled', true)->get() as $postPlatform) { + foreach ($this->post->postPlatforms()->enabled()->get() as $postPlatform) { PublishToSocialPlatform::dispatch($postPlatform); } } diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index a39c6b3b..27a3a90e 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -331,7 +331,7 @@ private function notifySuccess(Post $post): void $publishedPlatforms = $post->postPlatforms() ->with('socialAccount') - ->where('enabled', true) + ->enabled() ->get() ->filter(fn ($pp) => $pp->status === PostPlatformStatus::Published) ->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')') @@ -384,7 +384,7 @@ private function notifyFailure(Post $post): void $failedPlatforms = $post->postPlatforms() ->with('socialAccount') - ->where('enabled', true) + ->enabled() ->get() ->filter(fn ($pp) => $pp->status === PostPlatformStatus::Failed) ->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')') diff --git a/app/Jobs/SendNotification.php b/app/Jobs/SendNotification.php index 4065393b..1709cee5 100644 --- a/app/Jobs/SendNotification.php +++ b/app/Jobs/SendNotification.php @@ -14,6 +14,7 @@ use Illuminate\Mail\Mailable; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; +use Throwable; class SendNotification implements ShouldQueue { @@ -60,7 +61,7 @@ public function handle(): void } } - public function failed(\Throwable $exception): void + public function failed(Throwable $exception): void { Log::error('SendNotification job failed', [ 'user_id' => $this->user->id, diff --git a/app/Jobs/VerifyUpcomingPostConnections.php b/app/Jobs/VerifyUpcomingPostConnections.php new file mode 100644 index 00000000..b2186f7b --- /dev/null +++ b/app/Jobs/VerifyUpcomingPostConnections.php @@ -0,0 +1,365 @@ +workspaceId; + } + + public function handle(ConnectionVerifier $verifier): void + { + $workspace = Workspace::find($this->workspaceId); + + if (! $workspace) { + return; + } + + $postPlatforms = $this->atRiskPostPlatforms(); + + if ($postPlatforms->isEmpty()) { + return; + } + + $atRisk = new Collection; + + foreach ($postPlatforms->groupBy('social_account_id') as $group) { + $account = $group->first()->socialAccount; + + if (! $account) { + // The account was hard-deleted between the main query and its + // eager-loaded relation resolving (two separate queries) — + // nothing left to verify or warn about for this group. + continue; + } + + $group = $group->filter(fn (PostPlatform $pp) => $pp->post !== null); + + if ($group->isEmpty()) { + // Same race as above, but for the post: every row in this + // batch was hard-deleted between the main query and its + // eager-loaded relation resolving. + continue; + } + + // atRiskPostPlatforms()'s is_active guard only runs at query + // time; this job can take real wall-clock time working through + // a workspace, so re-check fresh (paused/deleted since then + // shouldn't burn an API call or warn about it). Keep workspace + // eager-loaded — SocialAccountObserver reads it when + // markAsTokenExpired() below updates the account (#255). + $account = SocialAccount::active()->with('workspace')->find($account->id); + + if (! $account) { + continue; + } + + if (in_array($account->status, [SocialAccountStatus::TokenExpired, SocialAccountStatus::Disconnected], true)) { + if ($this->recentlyWarnedAbout($account) || $this->recentlyDisconnected($account)) { + continue; + } + + // Already known broken from an earlier run — don't re-verify, + // just warn about the posts that entered the window since then. + $atRisk->push(['account' => $account, 'postPlatforms' => $group]); + + continue; + } + + if ($account->last_verified_at?->isAfter(now()->subMinutes(self::VERIFIED_WITHIN_MINUTES))) { + // Confirmed healthy recently enough — trust it instead of + // hitting the platform API again on every 15-minute tick. + continue; + } + + $nearestScheduledAt = $group->min(fn (PostPlatform $pp) => $pp->post->scheduled_at); + + if ($nearestScheduledAt->isAfter(now()->addMinutes(self::VERIFY_LEAD_MINUTES))) { + // Not close enough to publishing yet — defer the actual API + // call to a later run instead of spending budget checking + // every 15-minute tick for the full 1-hour risk window. + continue; + } + + try { + $verifier->verify($account); + $account->update(['last_verified_at' => now()]); + } catch (PlatformUnavailableException $e) { + Log::warning('Upcoming-post connection check skipped: platform unavailable', [ + 'account_id' => $account->id, + 'platform' => $account->platform->value, + 'error' => $e->getMessage(), + ]); + + continue; + } catch (TokenExpiredException $e) { + try { + // Re-check right before mutating: if the account is no longer + // Connected here, a concurrent process (e.g. RefreshExpiringTokens) + // beat us to discovering and announcing this same break via its + // own AccountDisconnected email. Only skip in that case — not + // when disconnected_at is fresh purely because our own update + // below is about to set it for the first time. + if ($account->refresh()->status !== SocialAccountStatus::Connected && $this->recentlyDisconnected($account)) { + continue; + } + + $account->markAsTokenExpired($e->getMessage(), notify: false); + $account->refresh(); + } catch (Exception $lockOrDbError) { + // Covers the account being deleted mid-run (refresh() + // throws ModelNotFoundException) as well as infrastructure + // failures inside markAsTokenExpired() itself (its + // Cache::lock() or ->update() call). An exception thrown + // from inside a catch block isn't routed to a sibling + // catch, so this must be handled here to avoid aborting + // the run for every other account in this workspace. + Log::error('Failed to mark account token_expired for upcoming-post check', [ + 'account_id' => $account->id, + 'platform' => $account->platform->value, + 'error' => $lockOrDbError->getMessage(), + ]); + + continue; + } + + // markAsTokenExpired() no-ops if it couldn't acquire the + // account's status lock (another process — e.g. a concurrent + // publish attempt or the daily check — holds it). Only warn + // once the status change is confirmed; a lost race here just + // means this account is picked up again on the next run. + if ($account->status !== SocialAccountStatus::TokenExpired) { + Log::warning('Upcoming-post connection check: could not mark account token_expired (status lock contended), deferring to next run', [ + 'account_id' => $account->id, + 'platform' => $account->platform->value, + ]); + + continue; + } + + $atRisk->push(['account' => $account, 'postPlatforms' => $group]); + } catch (Exception $e) { + Log::error('Failed to verify social account connection for upcoming-post check', [ + 'account_id' => $account->id, + 'platform' => $account->platform->value, + 'error' => $e->getMessage(), + ]); + + // Unknown error — don't mark as broken, retry next run. + continue; + } + } + + if ($atRisk->isEmpty()) { + return; + } + + $owner = $workspace->owner; + + if (! $owner) { + // No owner to notify — leave these rows unwarned so a future run + // (once the workspace has an owner) can pick them back up. + return; + } + + // Conditioned on the same "unwarned" window atRiskPostPlatforms() selected + // on, so a concurrent run that already claimed some or all of these + // exact rows (the ShouldBeUnique lock's TTL matches the schedule + // cadence, so two instances can briefly overlap if a run takes + // unusually long) never gets re-claimed here. lockForUpdate() closes + // the gap between reading which rows are still claimable and + // stamping them — without it, two overlapping runs could both read + // "unclaimed" for the same row before either writes. + $warnedIds = $atRisk->flatMap(fn (array $group) => $group['postPlatforms']->pluck('id')); + $claimedIds = DB::transaction(function () use ($warnedIds) { + $claimableIds = PostPlatform::whereIn('id', $warnedIds) + ->where(function ($query) { + $query->whereNull('connection_warning_sent_at') + ->orWhere('connection_warning_sent_at', '<', now()->subDay()); + }) + // Two overlapping runs can both claim rows here (see comment + // above the transaction) — locking in a consistent order + // (primary key) prevents them from deadlocking by acquiring + // the same two rows' locks in opposite order. + ->orderBy('id') + ->lockForUpdate() + ->pluck('id'); + + if ($claimableIds->isEmpty()) { + return $claimableIds; + } + + PostPlatform::whereIn('id', $claimableIds)->update(['connection_warning_sent_at' => now()]); + + return $claimableIds; + }, attempts: 3); + + if ($claimedIds->isEmpty()) { + return; + } + + // (Pre-existing trade-off, not introduced by this transaction: a + // crash between the DB transaction above and notifyOwner() below + // loses the warning for 24h, until atRiskPostPlatforms()'s re-check window.) + + // A concurrent run may have already claimed some (not all) of these + // rows between when $atRisk was built and the claim above — narrow + // the notification down to what THIS run actually claimed, so the + // email never lists an account/post pair another run is already + // notifying about. $claimedIds is a non-empty subset of $warnedIds, + // which is exactly the union of every group's post_platform ids, so + // at least one group is guaranteed to survive this filter. + $atRisk = $atRisk + ->map(function (array $group) use ($claimedIds) { + $group['postPlatforms'] = $group['postPlatforms']->filter( + fn (PostPlatform $pp) => $claimedIds->containsStrict($pp->id) + ); + + return $group; + }) + ->filter(fn (array $group) => $group['postPlatforms']->isNotEmpty()); + + $this->notifyOwner($owner, $workspace, $atRisk); + } + + /** + * Whether we've already sent a PostAtRisk notification covering this + * account within the cooldown window — checked against any of its + * post_platforms, not just the ones in the current batch. + */ + private function recentlyWarnedAbout(SocialAccount $account): bool + { + return PostPlatform::query() + ->where('social_account_id', $account->id) + ->where('connection_warning_sent_at', '>=', now()->subMinutes(self::RENOTIFY_COOLDOWN_MINUTES)) + ->exists(); + } + + /** + * Whether the account broke recently enough that another process (the + * daily sweep, a proactive token refresh) likely just sent its own + * AccountDisconnected email for the same event. + */ + private function recentlyDisconnected(SocialAccount $account): bool + { + return $account->disconnected_at?->isAfter(now()->subMinutes(self::RECENTLY_DISCONNECTED_GRACE_MINUTES)) ?? false; + } + + /** + * @return Collection + */ + private function atRiskPostPlatforms(): Collection + { + return PostPlatform::query() + ->where('status', PostPlatformStatus::Pending) + ->enabled() // PublishPost only iterates enabled platforms — an at-risk warning for a disabled one would be a false positive. + // A paused account already fails at publish time with + // posts.errors.account_inactive before any platform API call + // (PublishToSocialPlatform::handle()) — verifying it here would + // waste a real API call and, if the token also happens to be + // dead, warn the owner to "reconnect" an account they paused on + // purpose. whereHas() already excludes a null social_account_id + // (nothing to join to). + ->whereHas('socialAccount', fn ($query) => $query->where('is_active', true)) + ->where(function ($query) { + $query->whereNull('connection_warning_sent_at') + ->orWhere('connection_warning_sent_at', '<', now()->subDay()); + }) + ->whereHas('post', function ($query) { + $query->where('workspace_id', $this->workspaceId) + ->scheduled() + ->whereBetween('scheduled_at', [now(), now()->addHour()]); + }) + // socialAccount.workspace is eager-loaded even though this job + // never reads it directly — SocialAccountObserver::notifyOnboarding() + // (fired by the ->update() calls below via markAsTokenExpired()) + // accesses $account->workspace, and lazy loading is disabled + // app-wide. Dropping this eager load throws LazyLoadingViolationException + // the moment a second account in the same run gets updated (see #255). + ->with(['socialAccount.workspace', 'post']) + ->get(); + } + + /** + * @param Collection}> $atRisk + */ + private function notifyOwner(User $owner, Workspace $workspace, Collection $atRisk): void + { + $postPlatforms = $atRisk->flatMap(fn (array $group) => $group['postPlatforms']); + $postCount = $postPlatforms->pluck('post_id')->unique()->count(); + $postPlatformIds = $postPlatforms->pluck('id')->all(); + + SendNotification::dispatch( + user: $owner, + workspaceId: $workspace->id, + type: Type::PostAtRisk, + channel: Channel::Both, + title: trans_choice('notifications.post_at_risk.title', $postCount, ['count' => $postCount]), + body: $atRisk->map(fn (array $group) => $group['account']->platform->label().' ('.$group['account']->handle().')')->implode(', '), + data: ['workspace_id' => $workspace->id], + mailable: new PostAtRisk($workspace, $postPlatformIds, $postCount), + ); + } +} diff --git a/app/Jobs/VerifyWorkspaceConnections.php b/app/Jobs/VerifyWorkspaceConnections.php index 6d01c957..128965d9 100644 --- a/app/Jobs/VerifyWorkspaceConnections.php +++ b/app/Jobs/VerifyWorkspaceConnections.php @@ -120,7 +120,7 @@ private function notifyOwner(Collection $disconnectedAccounts): void } $accountNames = $disconnectedAccounts - ->map(fn ($account) => $account->platform->label().' (@'.($account->username ?? $account->display_name).')') + ->map(fn ($account) => $account->platform->label().' ('.$account->handle().')') ->implode(', '); SendNotification::dispatch( diff --git a/app/Mail/AccountDisconnected.php b/app/Mail/AccountDisconnected.php index 3c063521..e6345ac7 100644 --- a/app/Mail/AccountDisconnected.php +++ b/app/Mail/AccountDisconnected.php @@ -33,7 +33,7 @@ public function envelope(): Envelope public function content(): Content { $platformName = $this->account->platform->label(); - $accountName = $this->account->display_name ?? $this->account->username; + $accountName = $this->account->accountDisplayName(); $workspaceName = $this->account->workspace->name; return new Content( diff --git a/app/Mail/PostAtRisk.php b/app/Mail/PostAtRisk.php new file mode 100644 index 00000000..17479020 --- /dev/null +++ b/app/Mail/PostAtRisk.php @@ -0,0 +1,121 @@ + $postPlatformIds + */ + public function __construct( + public Workspace $workspace, + public array $postPlatformIds, + public int $count + ) {} + + public function envelope(): Envelope + { + return new Envelope( + subject: $this->subjectFor($this->count), + ); + } + + public function content(): Content + { + return new Content( + view: 'mail.post-at-risk', + with: [ + 'title' => 'Posts May Fail to Publish', + 'previewText' => $this->subjectFor($this->count), + 'intro' => "The following social accounts in your {$this->workspace->name} workspace need to be reconnected before these scheduled posts can publish:", + 'reconnectCta' => 'Please reconnect these accounts now to avoid missing your scheduled posts.', + 'buttonText' => 'Reconnect Accounts', + 'workspace' => $this->workspace, + 'atRiskGroups' => $this->atRiskGroups(), + 'url' => route('app.accounts'), + ], + ); + } + + /** + * @return Collection, postsLabel: string}> + */ + private function atRiskGroups(): Collection + { + if ($this->atRiskGroups !== null) { + return $this->atRiskGroups; + } + + $postPlatforms = PostPlatform::query() + ->with(['socialAccount', 'post']) + ->whereIn('id', $this->postPlatformIds) + ->get(); + + return $this->atRiskGroups = $postPlatforms->groupBy('social_account_id') + // The account can be null if it was hard-deleted between dispatch + // and send — nothing meaningful to render for it (no platform, no + // handle), so it's dropped rather than crashing the render. + ->filter(fn (Collection $group) => $group->first()->socialAccount !== null) + ->map(function (Collection $group) { + $postCount = $group->count(); + $times = $group->sortBy(fn ($pp) => $pp->post->scheduled_at) + ->map(fn ($pp) => $pp->post->scheduled_at->format('H:i')) + ->implode(', '); + $noun = $postCount === 1 ? 'post' : 'posts'; + + return [ + 'account' => $group->first()->socialAccount, + 'postPlatforms' => $group, + 'postsLabel' => "{$postCount} {$noun} scheduled: {$times} UTC", + ]; + })->values(); + } + + private function subjectFor(int $count): string + { + $noun = $count === 1 ? 'post is' : 'posts are'; + + return "{$count} {$noun} at risk in {$this->workspace->name}"; + } + + public function attachments(): array + { + return []; + } +} diff --git a/app/Mail/PostPublishFailed.php b/app/Mail/PostPublishFailed.php index 2d422d10..94415503 100644 --- a/app/Mail/PostPublishFailed.php +++ b/app/Mail/PostPublishFailed.php @@ -32,7 +32,7 @@ public function content(): Content { $failedPlatforms = $this->post->postPlatforms() ->with('socialAccount') - ->where('enabled', true) + ->enabled() ->get() ->filter(fn ($pp) => $pp->status === Status::Failed) ->map(fn ($pp) => [ diff --git a/app/Mail/PostPublished.php b/app/Mail/PostPublished.php index f6719619..ead767f8 100644 --- a/app/Mail/PostPublished.php +++ b/app/Mail/PostPublished.php @@ -32,7 +32,7 @@ public function content(): Content { $publishedPlatforms = $this->post->postPlatforms() ->with('socialAccount') - ->where('enabled', true) + ->enabled() ->get() ->filter(fn ($pp) => $pp->status === Status::Published) ->map(fn ($pp) => [ diff --git a/app/Mcp/Tools/Post/PublishPostTool.php b/app/Mcp/Tools/Post/PublishPostTool.php index 16278625..fb038870 100644 --- a/app/Mcp/Tools/Post/PublishPostTool.php +++ b/app/Mcp/Tools/Post/PublishPostTool.php @@ -47,7 +47,7 @@ public function handle(Request $request): Response|ResponseFactory return $denied; } - if (! $post->postPlatforms()->where('enabled', true)->exists()) { + if (! $post->postPlatforms()->enabled()->exists()) { return Response::error('Post has no enabled platforms. Use update-post-tool to enable at least one platform first.'); } diff --git a/app/Models/NotificationPreference.php b/app/Models/NotificationPreference.php index cd5f39d0..04d02a26 100644 --- a/app/Models/NotificationPreference.php +++ b/app/Models/NotificationPreference.php @@ -4,13 +4,16 @@ namespace App\Models; +use Database\Factories\NotificationPreferenceFactory; use Illuminate\Database\Eloquent\Concerns\HasUuids; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class NotificationPreference extends Model { - use HasUuids; + /** @use HasFactory */ + use HasFactory, HasUuids; protected $fillable = [ 'user_id', diff --git a/app/Models/Post.php b/app/Models/Post.php index 8d587d7f..4c1d764b 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -148,7 +148,7 @@ public function markAsFailed(): void public function allowedMediaTypes(): array { $platforms = $this->postPlatforms() - ->where('enabled', true) + ->enabled() ->with('socialAccount') ->get() ->pluck('socialAccount.platform') diff --git a/app/Models/PostPlatform.php b/app/Models/PostPlatform.php index 2063471c..35f0f0dd 100644 --- a/app/Models/PostPlatform.php +++ b/app/Models/PostPlatform.php @@ -8,6 +8,7 @@ use App\Enums\PostPlatform\Status; use App\Enums\SocialAccount\Platform as SocialPlatform; use Database\Factories\PostPlatformFactory; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -35,6 +36,7 @@ class PostPlatform extends Model 'error_context', 'published_at', 'meta', + 'connection_warning_sent_at', ]; protected function casts(): array @@ -47,6 +49,7 @@ protected function casts(): array 'published_at' => 'datetime', 'meta' => 'array', 'error_context' => 'array', + 'connection_warning_sent_at' => 'datetime', ]; } @@ -60,12 +63,22 @@ public function socialAccount(): BelongsTo return $this->belongsTo(SocialAccount::class); } + /** + * Only platforms still enabled for publishing — disabled ones are + * excluded from PublishPost, so anything else that mirrors publish + * eligibility (previews, validation, proactive checks) must too. + */ + public function scopeEnabled(Builder $query): Builder + { + return $query->where('post_platforms.enabled', true); + } + /** * Get display name, falling back to snapshot if account was deleted. */ public function getDisplayNameAttribute(): string { - return $this->socialAccount?->display_name ?? $this->platform_name ?? $this->platform->label(); + return $this->socialAccount?->accountDisplayName() ?? $this->platform_name ?? $this->platform->label(); } /** diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 21a3f573..61adc357 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -46,6 +46,7 @@ class SocialAccount extends Model 'error_message', 'disconnected_at', 'last_used_at', + 'last_verified_at', ]; protected $hidden = [ @@ -53,6 +54,11 @@ class SocialAccount extends Model 'refresh_token', ]; + protected $appends = [ + 'display_label', + 'handle_label', + ]; + protected function casts(): array { return [ @@ -64,6 +70,7 @@ protected function casts(): array 'token_expires_at' => 'datetime', 'disconnected_at' => 'datetime', 'last_used_at' => 'datetime', + 'last_verified_at' => 'datetime', 'scopes' => 'array', 'meta' => 'array', ]; @@ -145,6 +152,49 @@ protected function profileUrl(): Attribute ); } + /** + * "@handle" for notification bodies — the more specific identifier + * (username) wins over the friendlier display name when both are set. + * Every connector requests enough scope to always populate at least one + * of username/display_name (e.g. TikTok always requests user.info.profile); + * the platform label is a last-resort fallback, not an expected path. + */ + public function handle(): string + { + return '@'.($this->username ?: $this->display_name ?: $this->platform->label()); + } + + /** + * Friendly label for email templates — the display name wins over the + * username when both are set. + */ + public function accountDisplayName(): string + { + return $this->display_name ?: $this->username ?: $this->platform->label(); + } + + /** + * Frontend-facing mirror of accountDisplayName() — appended to JSON so + * Vue components stop re-implementing this fallback. + */ + protected function displayLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->accountDisplayName(), + ); + } + + /** + * Frontend-facing mirror of handle() without the "@" prefix — templates + * that render their own "@" (e.g. platform previews) use this instead. + */ + protected function handleLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->username ?: $this->display_name ?: $this->platform->label(), + ); + } + public function markAsDisconnected(string $errorMessage): void { $lock = Cache::lock("social_account_status:{$this->id}", 10); @@ -163,7 +213,7 @@ public function markAsDisconnected(string $errorMessage): void if ($wasConnected && $this->workspace->owner) { $placeholders = [ 'platform' => $this->platform->label(), - 'account' => '@'.($this->username ?? $this->display_name), + 'account' => $this->handle(), ]; SendNotification::dispatch( @@ -204,7 +254,7 @@ public function markAsTokenExpired(string $errorMessage, bool $notify = true): v if ($notify && $wasUsable && $this->workspace->owner) { $placeholders = [ 'platform' => $this->platform->label(), - 'account' => '@'.($this->username ?? $this->display_name), + 'account' => $this->handle(), ]; SendNotification::dispatch( diff --git a/app/Models/User.php b/app/Models/User.php index c730af1e..dd9795db 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -116,7 +116,7 @@ public function wantsEmailFor(NotificationType $type): bool return match ($type) { NotificationType::PostPublished => $preference->post_published, NotificationType::PostFailed, NotificationType::PostPartiallyPublished => $preference->post_failed, - NotificationType::AccountDisconnected => $preference->account_disconnected, + NotificationType::AccountDisconnected, NotificationType::PostAtRisk => $preference->account_disconnected, NotificationType::MentionedInComment => $preference->mentioned_in_comment ?? true, default => true, }; diff --git a/app/Rules/ContentTypeCompatibleWithMedia.php b/app/Rules/ContentTypeCompatibleWithMedia.php index 98372859..e8fd27cf 100644 --- a/app/Rules/ContentTypeCompatibleWithMedia.php +++ b/app/Rules/ContentTypeCompatibleWithMedia.php @@ -77,7 +77,7 @@ public static function entriesForUpdate(Post $post, ?array $requestPlatforms): a ])->all(); } - return $post->postPlatforms()->where('enabled', true)->get()->values() + return $post->postPlatforms()->enabled()->get()->values() ->map(fn ($postPlatform, $index): array => [ 'key' => "platforms.{$index}.content_type", 'content_type' => $postPlatform->content_type?->value, diff --git a/app/Services/Image/TemplateImageGenerator.php b/app/Services/Image/TemplateImageGenerator.php index 64fc1193..a12b3ff9 100644 --- a/app/Services/Image/TemplateImageGenerator.php +++ b/app/Services/Image/TemplateImageGenerator.php @@ -362,7 +362,7 @@ private function renderFooter(ImageInterface $canvas, SocialAccount $socialAccou $footerColor = '#9ca3af'; $username = $socialAccount->username ?? ''; - $displayName = $socialAccount->display_name ?? ''; + $displayName = $socialAccount->display_label; // Footer row anchored from the bottom: avatar + handle + displayName // share the same vertical center so they line up cleanly. @@ -748,7 +748,7 @@ private function drawTweetCardContent(ImageInterface $canvas, mixed $core, Socia $nameX = $avatarX + $avatarSize + 16; - $displayNameText = $socialAccount->display_name ?? ''; + $displayNameText = $socialAccount->display_label; $handleText = '@'.($socialAccount->username ?? ''); $nameBox = $fontBold ? imagettfbbox($nameSize, 0, $fontBold, $displayNameText) : [0, 0, 0, 0, 0, 0, 0, 0]; $handleBox = $fontLight ? imagettfbbox($handleSize, 0, $fontLight, $handleText) : [0, 0, 0, 0, 0, 0, 0, 0]; diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 2ae7570f..954e8329 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -6,6 +6,15 @@ use App\Enums\SocialAccount\Platform; use App\Exceptions\PlatformUnavailableException; +use App\Exceptions\Social\BlueskyPublishException; +use App\Exceptions\Social\DiscordPublishException; +use App\Exceptions\Social\LinkedInPublishException; +use App\Exceptions\Social\MastodonPublishException; +use App\Exceptions\Social\PinterestPublishException; +use App\Exceptions\Social\TelegramPublishException; +use App\Exceptions\Social\TikTokPublishException; +use App\Exceptions\Social\XPublishException; +use App\Exceptions\Social\YouTubePublishException; use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; use App\Services\Social\Discord\DiscordClient; @@ -38,6 +47,16 @@ public function verify(SocialAccount $account): bool try { return $this->callVerifyEndpoint($account); } catch (TokenExpiredException $e) { + if (! $account->platform->hasTokenRefreshFlow()) { + // Facebook/InstagramFacebook (Page tokens) and Mastodon + // tokens don't expire, and Telegram/Discord authenticate + // with one bot token shared across every connected account + // of that platform — none of them have anything to refresh, + // so retrying would just repeat this identical rejection + // while burning a call against a budget shared app-wide. + throw $e; + } + // Verify returned 401: the access_token is actually invalid. // Refresh and retry once with the new token. return $this->refreshThenVerify($account, $e); @@ -365,11 +384,18 @@ private function verifyLinkedIn(SocialAccount $account): bool ]) ->get(config('trypost.platforms.linkedin.api').'/rest/userinfo'); - if ($response->status() === 401) { + if (LinkedInPublishException::isConfirmedDeadToken($response)) { throw new TokenExpiredException('LinkedIn access token is invalid or expired'); } - return $response->successful(); + if ($response->successful()) { + return true; + } + + throw new PlatformUnavailableException( + "{$account->platform->label()} verify failed ({$response->status()}).", + $response->status(), + ); } private function verifyLinkedInPage(SocialAccount $account): bool @@ -383,11 +409,18 @@ private function verifyLinkedInPage(SocialAccount $account): bool 'q' => 'roleAssignee', ]); - if ($response->status() === 401) { + if (LinkedInPublishException::isConfirmedDeadToken($response)) { throw new TokenExpiredException('LinkedIn Page access token is invalid or expired'); } - return $response->successful(); + if ($response->successful()) { + return true; + } + + throw new PlatformUnavailableException( + "{$account->platform->label()} verify failed ({$response->status()}).", + $response->status(), + ); } private function verifyX(SocialAccount $account): bool @@ -395,11 +428,18 @@ private function verifyX(SocialAccount $account): bool $response = Http::withToken($account->access_token) ->get(config('trypost.platforms.x.api').'/users/me'); - if ($response->status() === 401) { + if (XPublishException::isConfirmedDeadToken($response)) { throw new TokenExpiredException('X access token is invalid or expired'); } - return $response->successful(); + if ($response->successful()) { + return true; + } + + throw new PlatformUnavailableException( + "{$account->platform->label()} verify failed ({$response->status()}).", + $response->status(), + ); } private function verifyInstagram(SocialAccount $account): bool @@ -460,14 +500,22 @@ private function verifyTikTok(SocialAccount $account): bool 'fields' => 'open_id,display_name', ]); - $body = $response->json() ?? []; - $errorCode = $body['error']['code'] ?? null; - - if ($response->status() === 401 || in_array($errorCode, ['access_token_invalid', 'access_token_expired', 10001, 10002])) { + // 401 here (unlike a publish-time 401, which TikTok also returns for + // scope_not_authorized/scope_permission_missed) is unambiguous: this + // endpoint only needs the always-granted user.info.basic scope, so a + // 401 can't be a scope gap. See TikTokPublishException::isConfirmedDeadToken(). + if (TikTokPublishException::isConfirmedDeadToken($response) || $response->status() === 401) { throw new TokenExpiredException('TikTok access token is invalid or expired'); } - return $response->successful(); + if ($response->successful()) { + return true; + } + + throw new PlatformUnavailableException( + "{$account->platform->label()} verify failed ({$response->status()}).", + $response->status(), + ); } private function verifyYouTube(SocialAccount $account): bool @@ -478,11 +526,18 @@ private function verifyYouTube(SocialAccount $account): bool 'mine' => 'true', ]); - if ($response->status() === 401) { + if (YouTubePublishException::isConfirmedDeadToken($response)) { throw new TokenExpiredException('YouTube access token is invalid or expired'); } - return $response->successful(); + if ($response->successful()) { + return true; + } + + throw new PlatformUnavailableException( + "{$account->platform->label()} verify failed ({$response->status()}).", + $response->status(), + ); } private function verifyPinterest(SocialAccount $account): bool @@ -490,11 +545,18 @@ private function verifyPinterest(SocialAccount $account): bool $response = Http::withToken($account->access_token) ->get(config('trypost.platforms.pinterest.api').'/user_account'); - if ($response->status() === 401) { + if (PinterestPublishException::isConfirmedDeadToken($response)) { throw new TokenExpiredException('Pinterest access token is invalid or expired'); } - return $response->successful(); + if ($response->successful()) { + return true; + } + + throw new PlatformUnavailableException( + "{$account->platform->label()} verify failed ({$response->status()}).", + $response->status(), + ); } private function verifyBluesky(SocialAccount $account): bool @@ -506,14 +568,18 @@ private function verifyBluesky(SocialAccount $account): bool 'actor' => $account->platform_user_id, ]); - $body = $response->json() ?? []; - $error = $body['error'] ?? null; - - if ($error === 'ExpiredToken' || $error === 'InvalidToken') { + if (BlueskyPublishException::isConfirmedDeadToken($response)) { throw new TokenExpiredException('Bluesky access token is invalid or expired'); } - return $response->successful(); + if ($response->successful()) { + return true; + } + + throw new PlatformUnavailableException( + "{$account->platform->label()} verify failed ({$response->status()}).", + $response->status(), + ); } private function verifyTelegram(SocialAccount $account): bool @@ -523,13 +589,31 @@ private function verifyTelegram(SocialAccount $account): bool 'chat_id' => data_get($account->meta, 'chat_id'), ]); - return $response->successful() && data_get($response->json(), 'ok') === true; + if ($response->successful() && data_get($response->json(), 'ok') === true) { + return true; + } + + if (TelegramPublishException::isConfirmedDeadChat($response)) { + throw new TokenExpiredException('Telegram bot no longer has access to the chat'); + } + + throw new PlatformUnavailableException("Telegram getChat failed ({$response->status()}).", $response->status()); } private function verifyDiscord(SocialAccount $account): bool { // The guild endpoint succeeds only while the bot is still a member. - return app(DiscordClient::class)->getGuild((string) $account->platform_user_id)->successful(); + $response = app(DiscordClient::class)->getGuild((string) $account->platform_user_id); + + if ($response->successful()) { + return true; + } + + if (DiscordPublishException::isConfirmedDeadGuild($response)) { + throw new TokenExpiredException('Discord bot no longer has access to the guild'); + } + + throw new PlatformUnavailableException("Discord guild lookup failed ({$response->status()}).", $response->status()); } private function verifyMastodon(SocialAccount $account): bool @@ -539,10 +623,22 @@ private function verifyMastodon(SocialAccount $account): bool $response = Http::withToken($account->access_token) ->get("{$instance}/api/v1/accounts/verify_credentials"); - if ($response->status() === 401 || $response->status() === 403) { + // 403 here (unlike a publish-time 403 on the write-scoped /statuses + // endpoint) means even read access is gone — verify_credentials is + // the lowest-privilege endpoint every authorized app token can + // reach, so a 403 confirms total revocation, not a scope gap. See + // MastodonPublishException::isConfirmedDeadToken(). + if (MastodonPublishException::isConfirmedDeadToken($response) || $response->status() === 403) { throw new TokenExpiredException('Mastodon access token is invalid or expired'); } - return $response->successful(); + if ($response->successful()) { + return true; + } + + throw new PlatformUnavailableException( + "{$account->platform->label()} verify failed ({$response->status()}).", + $response->status(), + ); } } diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index cfb57b80..15cc7104 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -140,7 +140,7 @@ public static function assertStoredPostPublishable(Post $post): void { $errors = []; - foreach ($post->postPlatforms()->where('enabled', true)->get()->values() as $index => $postPlatform) { + foreach ($post->postPlatforms()->enabled()->get()->values() as $index => $postPlatform) { $violation = self::requiredMetaViolation($postPlatform->platform, $postPlatform->meta); if ($violation !== null) { diff --git a/database/factories/NotificationPreferenceFactory.php b/database/factories/NotificationPreferenceFactory.php new file mode 100644 index 00000000..2a5901df --- /dev/null +++ b/database/factories/NotificationPreferenceFactory.php @@ -0,0 +1,31 @@ + + */ +class NotificationPreferenceFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'post_published' => true, + 'post_failed' => true, + 'account_disconnected' => true, + 'mentioned_in_comment' => true, + ]; + } +} diff --git a/database/migrations/2026_08_08_090000_add_connection_warning_sent_at_to_post_platforms_table.php b/database/migrations/2026_08_08_090000_add_connection_warning_sent_at_to_post_platforms_table.php new file mode 100644 index 00000000..66850b74 --- /dev/null +++ b/database/migrations/2026_08_08_090000_add_connection_warning_sent_at_to_post_platforms_table.php @@ -0,0 +1,26 @@ +timestamp('connection_warning_sent_at')->nullable()->after('error_context'); + $table->index(['status', 'enabled', 'connection_warning_sent_at']); + }); + } + + public function down(): void + { + Schema::table('post_platforms', function (Blueprint $table) { + $table->dropIndex(['status', 'enabled', 'connection_warning_sent_at']); + $table->dropColumn('connection_warning_sent_at'); + }); + } +}; diff --git a/database/migrations/2026_08_08_204549_add_last_verified_at_to_social_accounts_table.php b/database/migrations/2026_08_08_204549_add_last_verified_at_to_social_accounts_table.php new file mode 100644 index 00000000..d4de8b8c --- /dev/null +++ b/database/migrations/2026_08_08_204549_add_last_verified_at_to_social_accounts_table.php @@ -0,0 +1,24 @@ +timestamp('last_verified_at')->nullable()->after('last_used_at'); + }); + } + + public function down(): void + { + Schema::table('social_accounts', function (Blueprint $table) { + $table->dropColumn('last_verified_at'); + }); + } +}; diff --git a/lang/ar/notifications.php b/lang/ar/notifications.php index 552b5028..799c1bc7 100644 --- a/lang/ar/notifications.php +++ b/lang/ar/notifications.php @@ -15,4 +15,7 @@ 'title' => 'يحتاج حساب :platform إلى إعادة الربط', 'body' => 'انتهت جلسة :account — يرجى إعادة الربط لمواصلة النشر', ], + 'post_at_risk' => [ + 'title' => '{1} منشور واحد قادم معرض للخطر|{2} منشوران قادمان معرضان للخطر|[3,10] :count منشورات قادمة معرضة للخطر|[11,*] :count منشورًا قادمًا معرضًا للخطر', + ], ]; diff --git a/lang/de/notifications.php b/lang/de/notifications.php index 9d91e8d8..8abf45f2 100644 --- a/lang/de/notifications.php +++ b/lang/de/notifications.php @@ -15,4 +15,7 @@ 'title' => ':platform-Konto muss erneut verbunden werden', 'body' => 'Sitzung von :account abgelaufen – bitte verbinde es erneut, um weiter zu posten', ], + 'post_at_risk' => [ + 'title' => '{1} :count bevorstehender Beitrag ist gefährdet|[2,*] :count bevorstehende Beiträge sind gefährdet', + ], ]; diff --git a/lang/el/notifications.php b/lang/el/notifications.php index 88f8ff08..ebb9a513 100644 --- a/lang/el/notifications.php +++ b/lang/el/notifications.php @@ -15,4 +15,7 @@ 'title' => 'Ο λογαριασμός :platform χρειάζεται επανασύνδεση', 'body' => 'Η συνεδρία :account έληξε — επανασυνδεθείτε για να συνεχίσετε να δημοσιεύετε', ], + 'post_at_risk' => [ + 'title' => '{1} :count επερχόμενη ανάρτηση κινδυνεύει|[2,*] :count επερχόμενες αναρτήσεις κινδυνεύουν', + ], ]; diff --git a/lang/en/notifications.php b/lang/en/notifications.php index 338fd5ee..a51f6fa1 100644 --- a/lang/en/notifications.php +++ b/lang/en/notifications.php @@ -15,4 +15,7 @@ 'title' => ':platform account needs to be reconnected', 'body' => ':account session expired — please reconnect to keep posting', ], + 'post_at_risk' => [ + 'title' => '{1} :count upcoming post is at risk|[2,*] :count upcoming posts are at risk', + ], ]; diff --git a/lang/es/notifications.php b/lang/es/notifications.php index 0f6620aa..7956aa38 100644 --- a/lang/es/notifications.php +++ b/lang/es/notifications.php @@ -15,4 +15,7 @@ 'title' => 'Cuenta de :platform necesita reconectarse', 'body' => 'La sesión de :account expiró — reconéctala para seguir publicando', ], + 'post_at_risk' => [ + 'title' => '{1} :count próxima publicación está en riesgo|[2,*] :count próximas publicaciones están en riesgo', + ], ]; diff --git a/lang/fr/notifications.php b/lang/fr/notifications.php index dd8b0b47..4d12beed 100644 --- a/lang/fr/notifications.php +++ b/lang/fr/notifications.php @@ -15,4 +15,7 @@ 'title' => 'Le compte :platform doit être reconnecté', 'body' => 'La session de :account a expiré — veuillez reconnecter pour continuer à publier', ], + 'post_at_risk' => [ + 'title' => '{1} :count publication à venir est à risque|[2,*] :count publications à venir sont à risque', + ], ]; diff --git a/lang/it/notifications.php b/lang/it/notifications.php index 23e60250..ec283247 100644 --- a/lang/it/notifications.php +++ b/lang/it/notifications.php @@ -15,4 +15,7 @@ 'title' => 'L\'account :platform deve essere ricollegato', 'body' => 'Sessione di :account scaduta — ricollegalo per continuare a pubblicare', ], + 'post_at_risk' => [ + 'title' => '{1} :count post imminente è a rischio|[2,*] :count post imminenti sono a rischio', + ], ]; diff --git a/lang/ja/notifications.php b/lang/ja/notifications.php index f2f2ccb2..b839c199 100644 --- a/lang/ja/notifications.php +++ b/lang/ja/notifications.php @@ -15,4 +15,7 @@ 'title' => ':platform アカウントの再接続が必要です', 'body' => ':account のセッションの有効期限が切れました — 投稿を続けるには再接続してください', ], + 'post_at_risk' => [ + 'title' => '{1} :count 件の予定投稿にリスクがあります|[2,*] :count 件の予定投稿にリスクがあります', + ], ]; diff --git a/lang/ko/notifications.php b/lang/ko/notifications.php index a7534682..25abc437 100644 --- a/lang/ko/notifications.php +++ b/lang/ko/notifications.php @@ -15,4 +15,7 @@ 'title' => ':platform 계정을 재연결해야 합니다', 'body' => ':account 세션이 만료되었습니다 — 계속 게시하려면 재연결하세요', ], + 'post_at_risk' => [ + 'title' => '{1} 예정된 게시물 :count건이 위험합니다|[2,*] 예정된 게시물 :count건이 위험합니다', + ], ]; diff --git a/lang/nl/notifications.php b/lang/nl/notifications.php index aa97841e..f7de37d4 100644 --- a/lang/nl/notifications.php +++ b/lang/nl/notifications.php @@ -15,4 +15,7 @@ 'title' => ':platform-account moet opnieuw worden gekoppeld', 'body' => 'Sessie van :account verlopen — koppel opnieuw om te blijven posten', ], + 'post_at_risk' => [ + 'title' => '{1} :count aankomende post loopt risico|[2,*] :count aankomende posts lopen risico', + ], ]; diff --git a/lang/pl/notifications.php b/lang/pl/notifications.php index 52c29ef6..c0ba0686 100644 --- a/lang/pl/notifications.php +++ b/lang/pl/notifications.php @@ -15,4 +15,7 @@ 'title' => 'Konto :platform wymaga ponownego połączenia', 'body' => 'Sesja :account wygasła — połącz ponownie, aby dalej publikować', ], + 'post_at_risk' => [ + 'title' => ':count nadchodzący post jest zagrożony|:count nadchodzące posty są zagrożone|:count nadchodzących postów jest zagrożonych', + ], ]; diff --git a/lang/pt-BR/notifications.php b/lang/pt-BR/notifications.php index f929f57c..43c078d5 100644 --- a/lang/pt-BR/notifications.php +++ b/lang/pt-BR/notifications.php @@ -15,4 +15,7 @@ 'title' => 'Conta do :platform precisa ser reconectada', 'body' => 'Sessão de :account expirou — reconecte pra continuar postando', ], + 'post_at_risk' => [ + 'title' => '{1} :count post agendado está em risco|[2,*] :count posts agendados estão em risco', + ], ]; diff --git a/lang/ru/notifications.php b/lang/ru/notifications.php index 85e0aa42..b92e54a4 100644 --- a/lang/ru/notifications.php +++ b/lang/ru/notifications.php @@ -15,4 +15,7 @@ 'title' => 'Аккаунт :platform требует переподключения', 'body' => 'Сессия :account истекла — переподключите, чтобы продолжить публикацию', ], + 'post_at_risk' => [ + 'title' => '{1} :count запланированный пост под угрозой|[2,4] :count запланированных поста под угрозой|[5,*] :count запланированных постов под угрозой', + ], ]; diff --git a/lang/tr/notifications.php b/lang/tr/notifications.php index 94ca6b1b..75679f67 100644 --- a/lang/tr/notifications.php +++ b/lang/tr/notifications.php @@ -15,4 +15,7 @@ 'title' => ':platform hesabının yeniden bağlanması gerekiyor', 'body' => ':account oturumunun süresi doldu — paylaşıma devam etmek için lütfen yeniden bağlanın', ], + 'post_at_risk' => [ + 'title' => '{1} :count planlanan gönderi risk altında|[2,*] :count planlanan gönderi risk altında', + ], ]; diff --git a/lang/uk/notifications.php b/lang/uk/notifications.php index 0987d451..bd93ce6f 100644 --- a/lang/uk/notifications.php +++ b/lang/uk/notifications.php @@ -15,4 +15,7 @@ 'title' => 'Акаунт :platform потрібно перепідключити', 'body' => 'Сесію :account завершено — перепідключіть, щоб продовжити публікацію', ], + 'post_at_risk' => [ + 'title' => '{1} :count запланована публікація під загрозою|[2,*] :count заплановані публікації під загрозою', + ], ]; diff --git a/lang/zh/notifications.php b/lang/zh/notifications.php index 643c912f..949e7e1c 100644 --- a/lang/zh/notifications.php +++ b/lang/zh/notifications.php @@ -15,4 +15,7 @@ 'title' => ':platform 账号需要重新连接', 'body' => ':account 会话已过期——请重新连接以继续发帖', ], + 'post_at_risk' => [ + 'title' => '{1} 有 :count 篇待发布的帖子存在风险|[2,*] 有 :count 篇待发布的帖子存在风险', + ], ]; diff --git a/maizzle/components/footer-authenticated.html b/maizzle/components/footer-authenticated.html new file mode 100644 index 00000000..66dba868 --- /dev/null +++ b/maizzle/components/footer-authenticated.html @@ -0,0 +1,48 @@ + + +

+ Open-source social media scheduling tool +

+ +

+ + Manage notifications + +

+ + + + + + + + + + + +

+ © @{{ date('Y') }} TryPost.it +

+ + diff --git a/maizzle/components/footer.html b/maizzle/components/footer.html index e184c769..0feba36c 100644 --- a/maizzle/components/footer.html +++ b/maizzle/components/footer.html @@ -1,22 +1,41 @@ - -

Open-source social media scheduling tool

- @if(isset($unsubscribe_url)) -

- - Unsubscribe - + + + + + + + + + + +

+ © @{{ date('Y') }} TryPost.it

- @endif - \ No newline at end of file + diff --git a/maizzle/images/social/discord.png b/maizzle/images/social/discord.png new file mode 100644 index 00000000..290fc454 Binary files /dev/null and b/maizzle/images/social/discord.png differ diff --git a/maizzle/images/social/github.png b/maizzle/images/social/github.png new file mode 100644 index 00000000..8caa67b5 Binary files /dev/null and b/maizzle/images/social/github.png differ diff --git a/maizzle/images/social/instagram.png b/maizzle/images/social/instagram.png new file mode 100644 index 00000000..8140f2da Binary files /dev/null and b/maizzle/images/social/instagram.png differ diff --git a/maizzle/images/social/x.png b/maizzle/images/social/x.png new file mode 100644 index 00000000..1033d816 Binary files /dev/null and b/maizzle/images/social/x.png differ diff --git a/maizzle/images/social/youtube.png b/maizzle/images/social/youtube.png new file mode 100644 index 00000000..33509fcb Binary files /dev/null and b/maizzle/images/social/youtube.png differ diff --git a/maizzle/templates/account-disconnected.html b/maizzle/templates/account-disconnected.html index 6b0d9b26..b572c9be 100644 --- a/maizzle/templates/account-disconnected.html +++ b/maizzle/templates/account-disconnected.html @@ -40,7 +40,7 @@

- + diff --git a/maizzle/templates/mentioned-in-comment.html b/maizzle/templates/mentioned-in-comment.html index d2d73da1..99b70565 100644 --- a/maizzle/templates/mentioned-in-comment.html +++ b/maizzle/templates/mentioned-in-comment.html @@ -30,7 +30,7 @@

- + diff --git a/maizzle/templates/post-at-risk.html b/maizzle/templates/post-at-risk.html new file mode 100644 index 00000000..1fa088ca --- /dev/null +++ b/maizzle/templates/post-at-risk.html @@ -0,0 +1,57 @@ + +
+ + + + +
+ + + + + + +
+

+ @{{ $title }} +

+ +

+ @{{ $intro }} +

+ + + @foreach($atRiskGroups as $group) + + + + @endforeach +
+
+
+
+ @{{ $group['account']->platform->label() }} + - @{{ $group['account']->accountDisplayName() }} +
+ @{{ $group['postsLabel'] }} +
+
+
+
+ +

+ @{{ $reconnectCta }} +

+ + + +
+ + @{{ $buttonText }} → + +
+
+ +
+
+
diff --git a/maizzle/templates/post-publish-failed.html b/maizzle/templates/post-publish-failed.html index 206dc8b8..b0f986ba 100644 --- a/maizzle/templates/post-publish-failed.html +++ b/maizzle/templates/post-publish-failed.html @@ -42,7 +42,7 @@

- + diff --git a/maizzle/templates/post-published.html b/maizzle/templates/post-published.html index ad79ddad..bac50117 100644 --- a/maizzle/templates/post-published.html +++ b/maizzle/templates/post-published.html @@ -42,7 +42,7 @@

- + diff --git a/maizzle/templates/workspace-connections-disconnected.html b/maizzle/templates/workspace-connections-disconnected.html index 3e94c864..d9de6a73 100644 --- a/maizzle/templates/workspace-connections-disconnected.html +++ b/maizzle/templates/workspace-connections-disconnected.html @@ -24,9 +24,7 @@

@{{ $account->platform->label() }} - @if($account->display_name || $account->username) - - @{{ $account->display_name ?? $account->username }} - @endif + - @{{ $account->accountDisplayName() }}
@@ -58,7 +56,7 @@

- + diff --git a/public/images/emails/social/discord.png b/public/images/emails/social/discord.png new file mode 100644 index 00000000..290fc454 Binary files /dev/null and b/public/images/emails/social/discord.png differ diff --git a/public/images/emails/social/github.png b/public/images/emails/social/github.png new file mode 100644 index 00000000..8caa67b5 Binary files /dev/null and b/public/images/emails/social/github.png differ diff --git a/public/images/emails/social/instagram.png b/public/images/emails/social/instagram.png new file mode 100644 index 00000000..8140f2da Binary files /dev/null and b/public/images/emails/social/instagram.png differ diff --git a/public/images/emails/social/x.png b/public/images/emails/social/x.png new file mode 100644 index 00000000..1033d816 Binary files /dev/null and b/public/images/emails/social/x.png differ diff --git a/public/images/emails/social/youtube.png b/public/images/emails/social/youtube.png new file mode 100644 index 00000000..33509fcb Binary files /dev/null and b/public/images/emails/social/youtube.png differ diff --git a/resources/js/components/MentionTextarea.vue b/resources/js/components/MentionTextarea.vue index 0a3c2772..85778b48 100644 --- a/resources/js/components/MentionTextarea.vue +++ b/resources/js/components/MentionTextarea.vue @@ -5,6 +5,7 @@ import { nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Textarea } from '@/components/ui/textarea'; +import { getInitials } from '@/composables/useInitials'; import debounce from '@/debounce'; import { search as searchMembers } from '@/routes/app/workspace/members'; @@ -294,7 +295,7 @@ onBeforeUnmount(() => closePopover()); > - {{ member.name.charAt(0).toUpperCase() }} + {{ getInitials(member.name) }}

{{ member.name }}

diff --git a/resources/js/components/SocialAccountsGrid.vue b/resources/js/components/SocialAccountsGrid.vue index 6e2bcc63..808b0c5f 100644 --- a/resources/js/components/SocialAccountsGrid.vue +++ b/resources/js/components/SocialAccountsGrid.vue @@ -19,6 +19,7 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; +import { getInitials } from '@/composables/useInitials'; import { useOAuthPopup } from '@/composables/useOAuthPopup'; import { getPlatformLogo } from '@/composables/usePlatformLogo'; import { toggle as toggleAccount } from '@/routes/app/accounts'; @@ -29,6 +30,8 @@ export interface SocialAccount { platform_user_id: string; username: string; display_name: string; + display_label: string; + handle_label: string; avatar_url: string; status: 'connected' | 'disconnected' | 'token_expired' | null; is_active: boolean; @@ -201,10 +204,7 @@ const isDisconnected = (account: SocialAccount | null): boolean => { v-if="platform.connected && platform.account" class="truncate text-sm text-muted-foreground" > - @{{ - platform.account.username || - platform.account.display_name - }} + @{{ platform.account.handle_label }}

{{ trans('accounts.not_connected') }} @@ -244,13 +244,13 @@ const isDisconnected = (account: SocialAccount | null): boolean => { :src="platform.account.avatar_url" /> - {{ platform.account.display_name?.charAt(0) }} + {{ getInitials(platform.account.display_label) }} - {{ platform.account.display_name }} + {{ platform.account.display_label }}

diff --git a/resources/js/components/accounts/NetworkConnectGrid.vue b/resources/js/components/accounts/NetworkConnectGrid.vue index e1532f75..314beabd 100644 --- a/resources/js/components/accounts/NetworkConnectGrid.vue +++ b/resources/js/components/accounts/NetworkConnectGrid.vue @@ -27,6 +27,8 @@ export interface ConnectedAccount { network: string; username: string; display_name: string; + display_label: string; + handle_label: string; avatar_url: string | null; status: 'connected' | 'disconnected' | 'token_expired' | null; } @@ -170,7 +172,7 @@ const { openOAuthPopup } = useOAuthPopup((result) => { const disconnectAccount = (account: ConnectedAccount) => { disconnectModal.value?.open({ url: disconnect.url(account.id), - confirmText: account.username || account.display_name, + confirmText: account.handle_label, }); }; @@ -321,10 +323,7 @@ const cardState = computed((): Record => { v-else class="mt-0.5 truncate text-xs leading-tight text-foreground/70" > - {{ - cardConnection[platform.value]?.display_name || - cardConnection[platform.value]?.username - }} + {{ cardConnection[platform.value]?.display_label }}

diff --git a/resources/js/components/analytics/AnalyticsAccountSelector.vue b/resources/js/components/analytics/AnalyticsAccountSelector.vue index ea72b3fa..aa1125a9 100644 --- a/resources/js/components/analytics/AnalyticsAccountSelector.vue +++ b/resources/js/components/analytics/AnalyticsAccountSelector.vue @@ -14,6 +14,7 @@ import { CommandList, } from '@/components/ui/command'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { getInitials } from '@/composables/useInitials'; import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo'; import type { AnalyticsAccount } from './types'; @@ -53,9 +54,9 @@ const select = (account: AnalyticsAccount) => {