From a147c7414ba8f253af01c305a68d3f57a1a4a893 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 9 Aug 2026 10:10:39 -0400 Subject: [PATCH] feat: proactive connection check for at-risk posts + SocialAccount name centralization (#256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: gitignore .superpowers/ scratch workspace Holds per-plan subagent-driven-development artifacts (ledger, briefs, review packages) — scratch state, not part of the shipped codebase. * feat: add connection_warning_sent_at to post_platforms * feat: add PostAtRisk notification type and translations * fix: add user_id to NotificationPreferenceFactory definition for ->create() support * feat: add PostAtRisk mailable and email template * feat: add VerifyUpcomingPostConnections job * fix: guard VerifyUpcomingPostConnections against transient errors and cross-workspace leaks - Add a generic \Exception catch around ConnectionVerifier::verify() so a transient error (e.g. ConnectionException) on one account can't abort processing of every other at-risk account in the workspace run. - Eager-load socialAccount.workspace so markAsTokenExpired's observer chain never lazy-loads it — this only ever manifested once 2+ distinct accounts were hydrated in a single run (Eloquent only sets preventsLazyLoading on batch hydration of >1 row), which is exactly the multi-account scenario this job exists to handle. - Add covering tests: enabled=false posts are excluded, one workspace's at-risk posts never leak into another workspace's notification, and an unexpected exception on one account doesn't stop the rest of the run. * feat: add social:check-upcoming-connections command and schedule it * fix: add composite index for the 15-minute upcoming-post connection query post_platforms(status, connection_warning_sent_at) supports the filter both VerifyUpcomingPostConnections and social:check-upcoming-connections run every 15 minutes; without it, every run does a full table scan that only grows as posts accumulate. * fix: localize the PostAtRisk email's per-account line and label times as UTC The postsLabel line was the only hardcoded-English content in an otherwise fully-translated email, and it showed scheduled_at times with no timezone indicator even though the app stores everything in UTC. Add mail.post_at_risk.posts_label (pluralized, one entry per locale, mirroring each locale's existing post_at_risk.subject plural-boundary syntax) and use trans_choice() to build the line, with a literal " UTC" suffix left untranslated in every locale like a unit abbreviation. Also document why content() reassigns the public $atRiskGroups property instead of using a local variable (Mailable::buildViewData() overwrites with() data with same-named public properties). * fix: time-box the warning dedup and guard against orphaned/ownerless rows - Re-arm connection_warning_sent_at after a day instead of permanently suppressing it, so a post rescheduled back into the risk window after a stale warning is re-evaluated instead of silently skipped forever. - Exclude post_platforms with a null social_account_id from the at-risk query. With tries=1, dereferencing a null socialAccount relation would abort the whole workspace run, including already-detected broken accounts. - Resolve and check the workspace owner before stamping connection_warning_sent_at, so an ownerless workspace's posts are left un-warned (available to be picked up once it gets an owner) instead of being marked "warned" with no notification ever sent. Applied the same dedup time-boxing and null-account guard to the social:check-upcoming-connections dispatch query for consistency. * fix: PostAtRisk email is always English — drop the locale translation layer config('app.locale')/App::setLocale() is only ever set by the SetLocale web middleware, which reads a cookie off the incoming HTTP request. Every Mailable in this branch is built inside a queued job (SendNotification), which runs outside the HTTP request lifecycle entirely — no middleware, no cookie, nothing sets the locale there. So content() always resolved 'app.locale' to the static APP_LOCALE default ('en') regardless of the recipient's actual preference: the 16-locale mail.post_at_risk.* keys were dead weight from the start, matching an existing (pre-existing, out of scope here) gap in the sibling WorkspaceConnectionsDisconnected/ AccountDisconnected mailables. Replaces the trans_choice()/__() calls with plain English strings built directly in PostAtRisk, and removes the now-unused mail.post_at_risk.* block from all 16 locale files. Also strengthens the mailable test to assert the full "N post(s) scheduled: ... UTC" string, not just a fragment of it. * refactor: consolidate the two post_platforms migrations from this branch into one connection_warning_sent_at and its supporting index were added in two separate migrations (the column in the original task, the index during final review). Both are still unmerged/unshipped on this branch, so folding the index into the same migration that adds the column is safe and keeps the schema change to post_platforms as one unit instead of two. Verified with a full rollback + re-migrate cycle that the consolidated up()/down() is self-consistent. * refactor: add PostPlatform::scopeEnabled(), replace ->where('enabled', true) everywhere The raw where('enabled', true) clause was duplicated across 17 call sites in 12 files (13 including the 2 this branch added), all expressing the same rule PublishPost enforces at publish time: only enabled platforms are eligible. Added a scopeEnabled() to PostPlatform and swapped every query-builder call site to ->enabled(). Three call sites are intentionally left untouched: they filter an already-loaded relation Collection (->postPlatforms->where(...), no parens), which is Collection::where(), not a query scope — a query scope can't apply to an in-memory collection. No inverse (enabled = false) query pattern exists anywhere in the codebase — 'enabled' => false only ever appears as a write when a post is disabled/synced, never as a read filter — so no scopeDisabled() was added; nothing would call it. * test: cover re-armed post_platform where the account was reconnected The re-arm dedup fix (connection_warning_sent_at older than a day is treated as null) only had coverage for "still broken, warns again" and "too recent, stays skipped". Missing: the row gets re-evaluated (verify() is called, not skipped) but comes back healthy because the user reconnected in the meantime — nothing should change (no new warning, no notification, marker stays at its old value). * fix: dispatch-level uniqueness, index the enabled filter, close markAsTokenExpired race From a deep review pass on the whole branch: - VerifyUpcomingPostConnections now implements ShouldBeUnique (keyed on workspaceId, 300s window). withoutOverlapping() on the schedule only serializes the fast-dispatching command; a queue backlog could still let two jobs for the same workspace run concurrently, both mailing the owner for the same at-risk posts. - The composite index now covers enabled too (status, enabled, connection_warning_sent_at) — every query that uses it filters on all three, so the index previously required a heap fetch per row just to check enabled. - markAsTokenExpired() silently no-ops if it loses the account's status lock to a concurrent process (a publish attempt, the daily check). The job used to push the account into the at-risk notification regardless of whether the update actually landed. It now re-checks the account's status after the call and only warns if the transition is confirmed — a lost race just defers the account to the next run instead of sending a misleading "reconnect" email for an account whose status didn't change. Also includes an unrelated stray Pint fix (inline \Throwable -> imported) in SendNotification.php that had been sitting uncommitted. * refactor: centralize account handle/display name, expose to frontend, close review findings Adds SocialAccount::handle()/accountDisplayName() plus appended display_label/handle_label JSON fields, replacing duplicated username/display_name fallback logic scattered across platform previews, NetworkConnectGrid, PreviewTab, Calendar, and the post editor pages. Also closes the remaining findings from the final review on this branch: escapes the workspace name in PostAtRisk's intro (and drops the now-unnecessary raw-HTML rendering), fixes the tautological "dispatches once per workspace" test, adds plural/subject test coverage for PostAtRisk, raises VerifyUpcomingPostConnections' uniqueFor to cover the full schedule cadence, and updates a stale docblock. * test: cover draft-post exclusion, account status after PlatformUnavailableException Adds the two coverage gaps left open by the last review: a post still in Draft status inside the 1-hour window must not trigger a check or warning, and a PlatformUnavailableException must leave the account status untouched. Also drops the dedicated PostAtRisk XSS test — the intro is now plain Blade-escaped text, so the coverage is redundant with the framework's own escaping. * fix: close final review findings — i18n notification, empty-string fallback, missed refactor sites - Localize the in-app "post at risk" notification title in all 16 locales via trans_choice (the email stays English, unchanged) - Use ?: instead of ?? in handle()/accountDisplayName()/handleLabel() so an empty-string username/display_name still falls back, matching the old Vue || behavior - Migrate the 3 frontend sites the earlier sweep missed (Index.vue, SocialAccountsGrid.vue, ScheduleTab.vue) to display_label/handle_label - Fix avatar-initial fallback in the platform preview components to use display_label instead of raw display_name - Correct handle_label's TS type to string | null across 10 files to match the accessor's actual return type - Add test coverage for the command-level "already warned" dedup path and the in-app Notification row created alongside PostAtRisk's email * fix: notification storm, duplicate-email race, and queue payload bloat in upcoming-post checks Three correctness issues found by review, fixed after discussion: - An already-broken account could get a fresh PostAtRisk email every 15 minutes for as long as it stayed broken, if new posts kept entering the 1-hour risk window. Gated with a per-account 60-minute renotify cooldown. - Two concurrent jobs (RefreshExpiringTokens and this one) could each discover the same dead token and send their own email for it (AccountDisconnected + PostAtRisk) within the same tick. Gated with a 5-minute grace period, applied only when another process already transitioned the account before we got to it — not when we're the one making the transition. - PostAtRisk carried full SocialAccount/PostPlatform/Post model graphs on the queue payload, since SerializesModels can't reduce models nested inside a plain array/Collection to lightweight identifiers. It now carries only post_platform IDs and rehydrates at send time, with envelope()/content() sharing one memoized query so their counts can't disagree. Also replaces the account-health cache with a persisted SocialAccount.last_verified_at column, and narrows the actual platform API calls to only fire once a post's nearest scheduled_at is within 30 minutes — enough lead time to reconnect, without spending API budget checking a full hour out. * fix: replace dead unsubscribe link with notification preferences, finish display_label sweep The shared mail footer's unsubscribe link was permanently dead code (unsubscribe_url was never passed by any Mailable). Replaced it with a fixed "Manage notifications" link to the real settings page, via route('app.notifications.preferences'). Also closes out the remaining sites still computing the username/display_name fallback locally instead of reading the backend-computed display_label: 8 more Vue components (platform previews, per-platform post-editor settings, the AI post wizard, the automation Generate node config, and the analytics account selector) plus two PHP call sites (PostPlatform::getDisplayNameAttribute(), already fixed on main before this branch, and the template image generator's rendered footer text). * fix: only show "Manage notifications" on preference-driven emails The link doesn't make sense on transactional emails that always send regardless of notification preferences (password reset, email verification) or that go to recipients who may not even have an account yet (workspace invite) — and the settings page it points to requires login, which is actively broken for the first two. Split the shared footer into two Maizzle components: footer.html (plain) for the 3 transactional templates, footer-authenticated.html (adds the link) for the 6 that go through SendNotification and respect the recipient's notification preferences. * fix: lock PostAtRisk's subject to the dispatch-time count, expose handle_label from analytics PostAtRisk's subject/previewText were recomputed from a fresh DB query at send time, while the in-app notification's title (built in VerifyUpcomingPostConnections::notifyOwner()) used the count observed at dispatch time. If a post_platform row disappeared in between, the two could disagree. The count is now passed into the mailable explicitly and reused for both — the body's account/post details still rehydrate fresh from the DB, preserving the anti-staleness fix from earlier in this branch. Also adds handle_label to AnalyticsController's account payload, matching every other endpoint that serializes a SocialAccount. * fix: don't abort the whole workspace run if an account is deleted mid-verify An exception thrown inside a catch block isn't routed to a sibling catch, so $account->refresh() throwing ModelNotFoundException (the user disconnected/deleted the account in the brief window between this job loading it and handling the TokenExpiredException) escaped handle() entirely. With tries = 1, that killed the run for every other account in the same workspace, not just the deleted one. Also fixes an inconsistent placeholder in PlatformPreview.vue (handle_label: null instead of '', matching display_label). * fix: guard against deleted accounts, guarantee a non-empty account name Closes the last 4 findings from the sixth review round: - VerifyUpcomingPostConnections now skips a group whose account resolved to null (deleted between the main query and its eager-loaded relation), instead of an unguarded property access aborting the whole workspace's run - the same job's nested exception handler now covers any \Exception from markAsTokenExpired() (lock/DB failures), not just ModelNotFoundException - PostAtRisk drops a rehydrated group whose account no longer exists instead of crashing the render (verified: fails without the fix, passes with it) - AnalyticsController's handle_label field is now actually consumed by AnalyticsAccountSelector.vue instead of being unused payload Also closes a real gap: every connector requests enough OAuth scope to populate at least one of username/display_name (confirmed for TikTok, whose account.py comment implied otherwise but whose connect() scopes always include user.info.profile), so accountDisplayName()/handle()/ displayLabel/handleLabel now return a guaranteed non-empty string (falling back to the platform label only as a last resort) instead of being nullable. This removes the now-pointless @if guards around accountDisplayName() in the account-disconnected and post-at-risk email templates, and lets ~30 frontend files drop the `| null` from display_label/handle_label and the ?? undefined fallbacks that only existed to satisfy that type. * fix: drop the now-pointless ?? '' fallback on display_label in TemplateImageGenerator display_label is a guaranteed non-empty string (see 950558b4). * fix: correct social_account's TS type to nullable in Index.vue and Calendar.vue Both declared social_account as required while their own templates used optional chaining (pp.social_account?.display_label) — the type was lying. social_account_id is nullable and the account can be deleted (FK is nullOnDelete), so the field genuinely can be null. Swept every other social_account/socialAccount field in resources/js for the same mismatch; all others already declared it correctly. * Centralize avatar-initial extraction via getInitials() Replace hand-rolled .charAt(0)/.charAt(0).toUpperCase() avatar-initial logic across social account previews, the accounts grid, the analytics account selector, and the mention picker with the existing useInitials() composable already used by Avatar.vue. * Drop pointless display_label fallbacks now that it's always populated display_label is guaranteed non-empty (falls back to the platform label server-side), so || 'Channel' / || 'TryPost' / ?? platform were unreachable. * Fix cold-review findings: dead handle_label guard, slug leak, wrong post count - AnalyticsAccountSelector: the "@handle" line's guard/value must read the raw username (nullable — Facebook Pages and Telegram channels legitimately have none), not handle_label, which always resolves to something and made the guard permanently true. Drop the now-orphaned handle_label field from the analytics payload/type since nothing else in analytics used it. - PlatformPreview: the no-account-selected fallback now uses getPlatformLabel() instead of the raw platform slug, matching the backend's own last-resort label fallback. - VerifyUpcomingPostConnections: count distinct posts (post_id), not post_platform rows, so one post spanning multiple broken accounts doesn't inflate the at-risk count in the email subject and notification title. * Fix cold-review round 2: silent Telegram/Discord false negative, flaky email ordering, dead display_name - VerifyUpcomingPostConnections: ConnectionVerifier::verify() reports a dead Telegram/Discord connection by returning false rather than throwing. The job discarded that return value, so a bot removed from a channel/guild was stamped last_verified_at and silently trusted healthy for the next 40 minutes — no warning, post just fails at publish time. Route a false return through the same TokenExpiredException handling used by every other platform. - PostAtRisk: atRiskGroups() had no ORDER BY, so the per-account "N posts scheduled: H:i, H:i UTC" line rendered in arbitrary (physical row) order. Sort by scheduled_at before formatting. - Drop the orphaned display_name field from the analytics payload/type (superseded by display_label; nothing in resources/js/components/ analytics or pages/analytics read it). * Add social icons and copyright to email footers Icons match the trypost-site footer (outline @tabler/icons style, converted to PNG since email clients — notably Outlook desktop — don't render inline SVG). Reordered footer content: tagline, manage-notifications link, icons as the closing element, copyright line last. * Standardize connection-verify error classification across all 13 platforms Every platform now follows one contract: verify() returns true on a healthy connection, throws TokenExpiredException only on a confirmed dead connection, and PlatformUnavailableException on anything else (rate limit, 5xx, unrecognized). Previously most platforms silently returned false on anything but a 401, so callers (all of which only react via try/catch) could never distinguish "definitely dead" from "transient" — and Telegram/Discord never threw at all. Each platform's "is this confirmed dead" check now lives next to its existing publish-time error classifier (App\Exceptions\Social\*PublishException) instead of being re-typed inline in ConnectionVerifier, closing real, already-drifted gaps between the two paths: - TikTok and Mastodon both had a bare "status === 401/403" check shared between publish and verify, but TikTok's scope_not_authorized and Mastodon's write-scope 403 use the same status for a non-fatal scope gap, not a dead token — verify's lower-privilege endpoint keeps its own stricter check on top instead. - Telegram/Discord authenticate with one bot token shared across every connected account; a 401 means that shared token is misconfigured (an operator problem), never that one specific account is broken — excluded from both platforms' confirmed-dead checks accordingly. - Facebook/InstagramFacebook/Mastodon/Telegram/Discord have no per-account refresh flow at all, so a confirmed rejection now skips the pointless refresh-and-retry (Platform::hasTokenRefreshFlow()). Also fixes two bugs found while hardening VerifyUpcomingPostConnections: a post hard-deleted mid-run could crash the whole job for every other account in the batch (now filtered per group), and two overlapping runs of the same job could send duplicate PostAtRisk warnings (now a conditional claim on connection_warning_sent_at). * Skip paused accounts in upcoming-post connection checks, close claim race A paused (is_active=false) social account already fails at publish time before any platform API call, so it shouldn't trigger a proactive connection check or "reconnect" warning. Guard added at dispatch time (CheckUpcomingPostConnections) and re-checked fresh mid-run inside VerifyUpcomingPostConnections's per-account loop, since the job can take real wall-clock time working through a workspace and an account can be paused or deleted after the query-time guard already ran. Also wraps the connection_warning_sent_at claim in a SELECT ... FOR UPDATE transaction (ordered by id, 3 retries) to close a race between two overlapping runs of the same job double-claiming and double-emailing about the same post_platform. * Clarify "commit" wording in claim-transaction comment Reads ambiguously as a git commit on a PR diff; it means the DB transaction commit. --- .gitignore | 1 + app/Actions/Post/SyncPostPlatforms.php | 2 +- .../Commands/CheckUpcomingPostConnections.php | 45 + app/Console/Commands/RecoverStuckPosts.php | 6 +- app/Enums/Notification/Type.php | 1 + app/Enums/SocialAccount/Platform.php | 19 + .../Social/BlueskyPublishException.php | 15 +- .../Social/DiscordPublishException.php | 22 + .../Social/LinkedInPublishException.php | 13 +- .../Social/MastodonPublishException.php | 21 +- .../Social/PinterestPublishException.php | 13 +- .../Social/TelegramPublishException.php | 21 + .../Social/TikTokPublishException.php | 27 +- app/Exceptions/Social/XPublishException.php | 15 +- .../Social/YouTubePublishException.php | 21 +- .../Controllers/App/AnalyticsController.php | 2 +- app/Http/Controllers/App/PostController.php | 4 +- .../Resources/App/SocialAccountResource.php | 2 + app/Jobs/PublishPost.php | 2 +- app/Jobs/PublishToSocialPlatform.php | 4 +- app/Jobs/SendNotification.php | 3 +- app/Jobs/VerifyUpcomingPostConnections.php | 365 ++++ app/Jobs/VerifyWorkspaceConnections.php | 2 +- app/Mail/AccountDisconnected.php | 2 +- app/Mail/PostAtRisk.php | 121 ++ app/Mail/PostPublishFailed.php | 2 +- app/Mail/PostPublished.php | 2 +- app/Mcp/Tools/Post/PublishPostTool.php | 2 +- app/Models/NotificationPreference.php | 5 +- app/Models/Post.php | 2 +- app/Models/PostPlatform.php | 15 +- app/Models/SocialAccount.php | 54 +- app/Models/User.php | 2 +- app/Rules/ContentTypeCompatibleWithMedia.php | 2 +- app/Services/Image/TemplateImageGenerator.php | 4 +- app/Services/Social/ConnectionVerifier.php | 144 +- app/Support/PostPlatformMetaRules.php | 2 +- .../NotificationPreferenceFactory.php | 31 + ...arning_sent_at_to_post_platforms_table.php | 26 + ...t_verified_at_to_social_accounts_table.php | 24 + lang/ar/notifications.php | 3 + lang/de/notifications.php | 3 + lang/el/notifications.php | 3 + lang/en/notifications.php | 3 + lang/es/notifications.php | 3 + lang/fr/notifications.php | 3 + lang/it/notifications.php | 3 + lang/ja/notifications.php | 3 + lang/ko/notifications.php | 3 + lang/nl/notifications.php | 3 + lang/pl/notifications.php | 3 + lang/pt-BR/notifications.php | 3 + lang/ru/notifications.php | 3 + lang/tr/notifications.php | 3 + lang/uk/notifications.php | 3 + lang/zh/notifications.php | 3 + maizzle/components/footer-authenticated.html | 48 + maizzle/components/footer.html | 47 +- maizzle/images/social/discord.png | Bin 0 -> 2051 bytes maizzle/images/social/github.png | Bin 0 -> 1791 bytes maizzle/images/social/instagram.png | Bin 0 -> 1327 bytes maizzle/images/social/x.png | Bin 0 -> 1560 bytes maizzle/images/social/youtube.png | Bin 0 -> 1107 bytes maizzle/templates/account-disconnected.html | 2 +- maizzle/templates/mentioned-in-comment.html | 2 +- maizzle/templates/post-at-risk.html | 57 + maizzle/templates/post-publish-failed.html | 2 +- maizzle/templates/post-published.html | 2 +- .../workspace-connections-disconnected.html | 6 +- public/images/emails/social/discord.png | Bin 0 -> 2051 bytes public/images/emails/social/github.png | Bin 0 -> 1791 bytes public/images/emails/social/instagram.png | Bin 0 -> 1327 bytes public/images/emails/social/x.png | Bin 0 -> 1560 bytes public/images/emails/social/youtube.png | Bin 0 -> 1107 bytes resources/js/components/MentionTextarea.vue | 3 +- .../js/components/SocialAccountsGrid.vue | 12 +- .../accounts/NetworkConnectGrid.vue | 9 +- .../analytics/AnalyticsAccountSelector.vue | 13 +- resources/js/components/analytics/types.ts | 2 +- .../automations/config/GenerateNodeConfig.vue | 3 +- .../components/posts/create/AiPostWizard.vue | 5 +- .../posts/editor/DiscordSettings.vue | 7 +- .../posts/editor/FacebookSettings.vue | 5 +- .../posts/editor/InstagramSettings.vue | 5 +- .../posts/editor/LinkedInSettings.vue | 5 +- .../posts/editor/PinterestSettings.vue | 5 +- .../posts/editor/PostEditorTabs.vue | 2 + .../js/components/posts/editor/PreviewTab.vue | 4 +- .../components/posts/editor/ScheduleTab.vue | 3 +- .../posts/editor/TikTokSettings.vue | 5 +- .../posts/previews/BlueskyPreview.vue | 12 +- .../posts/previews/DiscordPreview.vue | 8 +- .../posts/previews/FacebookPreview.vue | 21 +- .../posts/previews/InstagramPreview.vue | 21 +- .../posts/previews/LinkedInPreview.vue | 12 +- .../posts/previews/MastodonPreview.vue | 8 +- .../posts/previews/PinterestPreview.vue | 8 +- .../posts/previews/PlatformPreview.vue | 5 + .../posts/previews/TelegramPreview.vue | 8 +- .../posts/previews/ThreadsPreview.vue | 13 +- .../posts/previews/TikTokPreview.vue | 16 +- .../js/components/posts/previews/XPreview.vue | 8 +- .../posts/previews/YouTubePreview.vue | 17 +- resources/js/pages/posts/Calendar.vue | 9 +- resources/js/pages/posts/Create.vue | 1 + resources/js/pages/posts/Edit.vue | 2 + resources/js/pages/posts/Index.vue | 5 +- resources/js/types/channel.ts | 1 + .../views/mail/account-disconnected.blade.php | 38 +- .../views/mail/email-verification.blade.php | 40 +- .../views/mail/mentioned-in-comment.blade.php | 38 +- resources/views/mail/password-reset.blade.php | 40 +- resources/views/mail/post-at-risk.blade.php | 161 ++ .../views/mail/post-publish-failed.blade.php | 38 +- resources/views/mail/post-published.blade.php | 38 +- ...rkspace-connections-disconnected.blade.php | 42 +- .../views/mail/workspace-invite.blade.php | 40 +- routes/console.php | 2 + tests/Feature/Auth/EmailVerificationTest.php | 13 + tests/Feature/Auth/PasswordResetTest.php | 13 + .../CheckUpcomingPostConnectionsTest.php | 187 ++ .../VerifyUpcomingPostConnectionsTest.php | 1583 +++++++++++++++++ tests/Feature/Mail/PostAtRiskTest.php | 194 ++ .../Social/ConnectionVerifierTest.php | 334 +++- .../Feature/Social/TelegramConnectionTest.php | 41 +- tests/Feature/YouTubeAnalyticsTest.php | 3 +- .../PostAtRiskNotificationPreferenceTest.php | 22 + .../Social/TikTokPublishExceptionTest.php | 20 + tests/Unit/Mail/AccountDisconnectedTest.php | 10 + tests/Unit/Mail/MentionedInCommentTest.php | 9 + tests/Unit/Mail/WorkspaceInviteTest.php | 9 + tests/Unit/PostPlatformTest.php | 19 + 132 files changed, 4229 insertions(+), 248 deletions(-) create mode 100644 app/Console/Commands/CheckUpcomingPostConnections.php create mode 100644 app/Jobs/VerifyUpcomingPostConnections.php create mode 100644 app/Mail/PostAtRisk.php create mode 100644 database/factories/NotificationPreferenceFactory.php create mode 100644 database/migrations/2026_08_08_090000_add_connection_warning_sent_at_to_post_platforms_table.php create mode 100644 database/migrations/2026_08_08_204549_add_last_verified_at_to_social_accounts_table.php create mode 100644 maizzle/components/footer-authenticated.html create mode 100644 maizzle/images/social/discord.png create mode 100644 maizzle/images/social/github.png create mode 100644 maizzle/images/social/instagram.png create mode 100644 maizzle/images/social/x.png create mode 100644 maizzle/images/social/youtube.png create mode 100644 maizzle/templates/post-at-risk.html create mode 100644 public/images/emails/social/discord.png create mode 100644 public/images/emails/social/github.png create mode 100644 public/images/emails/social/instagram.png create mode 100644 public/images/emails/social/x.png create mode 100644 public/images/emails/social/youtube.png create mode 100644 resources/views/mail/post-at-risk.blade.php create mode 100644 tests/Feature/Commands/CheckUpcomingPostConnectionsTest.php create mode 100644 tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php create mode 100644 tests/Feature/Mail/PostAtRiskTest.php create mode 100644 tests/Unit/Enums/PostAtRiskNotificationPreferenceTest.php 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 0000000000000000000000000000000000000000..290fc454200e653bb8498fa79720ca1701b69539 GIT binary patch literal 2051 zcmV+e2>kbnP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NJZAnByRA>e5S#N9UlRy9VcM_3AZCb=~(MV_J!(@kN@(7NY+P{rX5`&!?$uhC=HegKiykv(cYH zf0}cx(=cL(4jkBf0&{t)`A*rm1E{R5xq~tPddB!ua5~dzh-l7v|HufN-`2Kg$W0y$ zuFDAo_|J^7X>M{@xXd|wpYv#4bMwBl?m8;hr;^E3I2@i^Sh#Gbrs=!jcqPDRC2N@+ z$TNLDKHAfB>MJ*$;^Ly$7}JApGRs01R+W6buy}FtqQb(3-*$I*yCy@}NVx(P6>C@N zy8aQ2=7&FIGJd0Fad`SrHp-eWzBdQ z&Tn(d+-Nha(H*kPO2~(rR$U(sXY4~lZ2uOP2?e0Tcu00&7;Lv2c+zQaZyyeY){;I} zflUFxa|rkXXsUf`*b8hnvN;d@QPlOOj*dngyKb#9W*F;sI}%8?pTlD~XSnWxii$NW zd_LdTLK%Pt`uciib#``+3EuVWOG-*I^79KWAz*vM$6~RSEiHSGI{i|nlbX+`KO!@+ zVLzKJ@Z^_#kxv3{1j~ic?2Th8ZBiDP;|Ml{=N9Y1dF?-L%G6mC)%Wo2z~(5S zV6b{7fY{$OsKLLH%`++#LHRdB*FL8O*W|@lMX;=XqWYRfz6pacMA!(Ups80@AQ151 zB?~d`!Q=G=qr@*Ftv zP2<^IKdvloH<=qx7*8crFgP(f0!aF)nBx48ZbW?!2n3=xdC`r6PV^Jiw`VFC@Wt!B z2jF~$Jq-iKV!z3>JT{NYXes7I@cd~#5genXZpY|E^%K7MrNkH;(t*)03jukGOvhqu zRHmI{3uxHM_o64NPj(E0kNAA@i%G@kC?W3n-eyTZUcA6vKfa0qdqBac1b`i`0LG7s z!1V(*KFa`{DQh}0DkX&ax`LO5+~&#zjVmf@AH|Z^;d3_wC3loD<8U;}j|~l7?2>;c zJLk!C8Girf)7K{pqMiNvRa9`eJ=^yzsq+`9b;-Z?zK0MsA?wBDlpW{mNfu2DkfVd|ae<6pd{ z88d50%ubTyk(!FZ#F!8(0bs*?=d(Yg<4Pg44UtI1J_!Ka^p>z8Mm|XBMMw{ttR|05 zsmEWXq~UuO^yv4n-3X&iV4zGG9K3i8nY%0$00>0qi}y}l(2cNP z1EKxhRav>V3b%rv$P=OWD)hF=yri`a27^-qf$1l$antM^g zM-abVa9G->%@Wk}p)wm-vhkqysnK3wbd7>68-2RYmg9LhE{gx*xO6b+*5NpjI4H_d z$_FsVK7p`8y(1%k+}+mJro5Yy0x%7%DMj>0kWqP$jKRoTaJ1j=*IS`SK&{5lG&PMC zdfgidtwdk#!{t>s0ODHSZhg_Hu?n{jRY=|@aB<3+4VjN3f%l651)q9l4+sG%;{1JH z&{m%T!*2roNzf8_+85fh;|EXH8j*3DqqKaoO+LW2!IK7IaRW~>y99`kTX%O?XF);n zFjhgiBa(H@v9YDOxnWl_KQ*Vf_tZ~?g+(Yay0Z6fX5BYz_tNkTfF|Ic|0seufnhlU zvFbTt3MKUcHr0#Z*qbjQz4{1AI#f`wWInRK)Ts^NSHP=<{r8+QnNAr1DI$p81q+tc z`TYj|(GY(b<-lP-KC@nj@i(AyP2epM9QIqV61Zw-%a9PV^*u~($A5aBY;CQ_INj12 hNM|6Of&X&`{ss5H%Pjz_x;p>>002ovPDHLkV1h7ZwHW{a literal 0 HcmV?d00001 diff --git a/maizzle/images/social/github.png b/maizzle/images/social/github.png new file mode 100644 index 0000000000000000000000000000000000000000..8caa67b57e233a06773b1592b85d832bd5e2378d GIT binary patch literal 1791 zcmVPx#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NIX-PyuRA>e5SzBxrRT!SjZm$%D zw2JW(f|7z!@Pdh&06|DAHN7pV)MDU8CB8@``a+C^X!>T1V(=0$ilLM=>~6bQE^4xs zMEYPbNMhQcrpAhBwXL9~b$90Y{oS46pR+TwTj-Vtdy?7n-_HMk|GD*?Q$?9Avpw+t z?g2HI^cxzQ*J_&fst{^9?0?1AYZ%7iuCDfPa%rEeO$ksM%0=2bIl?Q{`034Od&V*2URn>Ss9B%(q*5$&7Ld{PIp}(T4%EK=9|0ELT z!dUF!A1*pu6tF1|Lf6$0R2ksOJQY|!(%86(dMcu-Vj1csNbFWp4%9;Rz{R~%kSi0rbM-PGhk<7K@EJWz)~4Vu;<`Gm@z0 zJf}Sq!0toG0=o!dT-ABUb}$9PCDwAU=AH>4Ysc<0GGhMVhs5C#&Tlekv$W@u>Hmj%gVIdIiF8jjwNH^ zu{{&Ou>Hslakg2(`FzrHEEx-r?U?|KPIE)_!Wz!!lXi*az`|pDCV;K%b8d*g3s2JZ zlFRZzQw)m5eal$5&NG40(F;8oI=aAHr6>^4-n5u})#(^TmVd%PSS((yby;BX0|Nu* z(xvwV(HrY58HRRQW#zp}U*Fj;E!i~IYC}WIPEAu^aQZY`j~zSW8PD@DM^0*MYfFN` zxpZ-{?*l34q8r1yBYmk!iLT+dv2i0VIC?9#Zu$QGeIjvjx!)1SHvz(gH=7lBv*|(a zmvD?YnF`3hf~-54R89o~<9*?9m`^!w?w~`V&@IVi(Gpx>@D8NZV46INk#=B#Yz%Kt ztMT^qwWG1bI2uVho2{>JUa9NaVf0XiEp#VviZr(3ov6!Ajd!3rMbY1f4v$xUMGP5+ zxuLVO{iL52=>oDreSJ#}-eV{Tk`sIT`xC9?sHUdnp`s$OAL3PV8^$ld9{wJ9*939_ zkAdx(m{7KKcenq<{CbiZ322%^0B%dQPlIP4Ax=S z*lWW8VVdT4&T|?dUxfdImJECnEw+x28?mEDJBBRzRO*c0qA7$?_z-~{$7$0Po1@XT zPq=fsc9xbF{AtMmXarZz`4;a|tGF^6@NLHphYwJP2^ZU(L{e|#&7-poTjUttH^o`* z0WsgHt7~#>rxO!H(hW^5|9sCr}bA2p4w+5K*DG=I%*gRJXq0VRjMHD+(01M zjUCB8mB!=oaZ67%Y#duH*X4`OSOZp|uC9)LygF|OsCO+HATGxL3C>^|>_c+&NHy<@ z?)p%!BVa+ndnI-x@sY(-jTwMvLiHuAb*vNZ4X(Q$v~L0gXn6Sivse%9*8^NGF|#Is zd7i}1(t;$m(JqsV)0wo%nm`(iKoW0{j3n+b45b-+H>Qr*hlJjmNw99QJQuN>aXjAf zH{^%X-=s+>v}v20?M%sX9RWW)gG|Y3CU%hAhSLtCT$yW5z%-TT5R8BfrfJ{UQ?>VX hHmjWNf!Q8V{sYVM9o@{U-Q55H002ovPDHLkV1hdJN>u;= literal 0 HcmV?d00001 diff --git a/maizzle/images/social/instagram.png b/maizzle/images/social/instagram.png new file mode 100644 index 0000000000000000000000000000000000000000..8140f2daf7890719460ce0fc71d1b9074a4400ba GIT binary patch literal 1327 zcmV+~1Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NGnMp)JRA>e5St?frkj(V6e$!v|Ix}y&(=$5l13eG? zn;sBJl#h&zi!&byAwMPXPsHF)#p3FaQ$s^H z9@*Rb?MD)(Oaj{);DWdAn1pm=jly)4jL4G_n^2`ucL<2pZ2((k_9AVt)=#T+l^f%{wHd+Dt?Od-^IS7gNlzd_*N zp(T?{2_7SRoOE_~mDs>Jhm3@=qJ8%SmDs}Qwxm>x=AeC*~{@JqfI|O{to0mv- zqTgeDiOE25;+@_GoHl$fmBRCtiUz*1?9F|k^Xhx~T3osV2DnG1udr4-`N5~7&(l+( zt@+1nlrPCRwE;<#<#NZy+CGUapEtDucuD!f@XTiOFY_^{qifN@_|I8gp3V^LRV+@nz`Lr%mwL+nP{R*!@|{@a$uV?qHn{;-J~f5 z--yW9u<*5(2B6WZF;deBeH%X44cgQ37MC5x!q-|FfG#ne7zZJ<=tI+~n?{6}^p5z-#@U{j(09xF`!-Zd|{C$yXny&tj05MNrb+hI2@@MV9!+FLW00Y1Ni~nNc l_Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NHf=NU{RA>e5Szl-zMHt`NyG;}S zKqLBKtwONzE;JV@_|}T?B~bA$*?)dv|Gr8HjoxR=L1AVxGWoEwl{(tlBe6y=*o!0R{ z#{(S?{FfeJD&eL%{j{d>PZ?wTR5%WtTj*DB+$cZ0wzhc7A2;~w*x2NluIsZzVcXgZ zg+lHdFKXQj=)i&~Pnax*XRNok>oYa4a4V)cmDP3j6{z;pqwAWIw~qnggfga0nr8My zII2c$QXahK9LF@S*F$^^;HT=5o6lpjrg?0jIi5F$xANSwZTqA*g{CPDSYBSZh#Z)& zCj>KdX(`9(sZ_R;SpP@hz;kyn7RwJ04|fY#&|alH=h{r6uuxF(Q5rx5mCCJ`&~3=X zNb1i=N3#zStXN!J=iGipUPtJM_U?V)eK*X1=BNDf@_gQp+Sh<$v3S$Ac@~k_7EEA3 zZ!D%SXxaplSJS*Ag`|6Vd^~Fk8oxe@^2FEI0757f=6^&O-=uM^vBRd>bwy3KT*SH{fWfT z%~I*w4+J}KVDJ)>cm_Ck@8Dp_M*8}aUmQGmDDCBW%VN`r;FmR?!ADik1xF{$iI`~` zYf$P)jgD_uD)!-GF@J?%lpFCl`xZkf!u%)9hw8TtDZjFkYe{*MB?+KukhVxC;}r5? zvxd#O@M_IxGMTE^i^cix@cMnXwoW5k~0A z6v}6Sb6L#YJ-4EIUhr$l03vpdcm9!J)U^x*74^0KAB7nL20QsPv`_ zd|1T=eq;l@Ya7A78iga{@m^wQP{&vr}^E zZCSSOB}pP|I&9ehmGX4LqVyhtm#<6GQF?20I3MmM7}ZS3mWE4`94U#`ci55ve#+Ac z>s@-YeTKmcvC@#t@NIlJ3V^}rN(PJFvTTFn-iBL zH?8+9lmS7N7owJy%BQiFTm>Bx_G~7TeT;Bak&-V-f|sffY(QA$9f7+Aop0&gBvLBf ztMEVuw5GfxH%RH-EK+_JG&LZK@B_(O2NIIF@?8sH{~=6RVHzNMb8FX-fTuQ9fstICyelm^gm9U%Tm4?_4% zyY8?g9KWMpgc0~;Uj02qAZ3H{e=%m!SMpBlc%b8fjtBmS9{3kk-OEPj#w5=G0000< KMNUMnLSTaKqvz8A literal 0 HcmV?d00001 diff --git a/maizzle/images/social/youtube.png b/maizzle/images/social/youtube.png new file mode 100644 index 0000000000000000000000000000000000000000..33509fcb8968d81cbc51c7f1e24b476b2635385b GIT binary patch literal 1107 zcmV-Z1g!gsP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NFy-7qtRA>e5S~PEmnSC!S!`lJ>}Es$CuNel4(CLbIe z?Ck03&2i3h&~6CcbByt>{rVifPLyGcZFpCOV8yMi&EneH+HSpZ>k=@{n}!hD4Cnk} zz0U2>3n6}TUYWA2cXrr(dWcdpjEQN^wOIh)9a7&`RTYDFqpvS}YGdQuN0?kt009D3 z@N9}UItntl@eq(W2_!yHJU$mfw_!}TXagWzu2lG(5Y9rWCJa#&h z(FZu!Cp1mm3&xydQg>1W+<^U2+UtEPRa?W@Dq}a`{V|#@|Eh3$h2s1O#@! z3~WtSR971suxR<@z#}d+9AZE(kT}u6h*g#q0e7jCdWLS3l;sr&5L^tesg7b*1fT(~ z^XfsLSA~QH$Em!nfuOKb0&QNqOf*&(EK}Wv#Sj(zv!!T?s(I-z59x> zCC3}TbqTSU<^&nQ!%F`F(x4Sl%t3&ici$JL+4;pV#;>+AQZs!bBY?pD6Z0(W=AR5B z|7K)l{H(t&x>95W>Olfxw>vucCJ=eR*jQlauh*|pee49PKzIg-#Nt*l5)kV_D@N~D zu@SJW-OI4;KZKR3R7*s<)#&K>_1d~-%dru_CDgVHvwM4@7f8$pf$@y4YrY*e*ydnO zj0C*N;b!GK!+SM{m&itT_`&=mjOm8aISJ>Mr*I_B`0KDFN{%D$2KCKpVk3YX8350Q z!x8h~T2sr*i@(F`8`s80ARN3{hrph+?ZwZHL)^b*j0EV!q6Bg9B+B!V9K8($qH`As zn*K&c04%%>0jrL=5Tud23k5Fa$Ov%m+=qk!T>f#%6wl&t7YUmFs!P3T=Ks+Qz|Kzj zOtW_aI&hRSW-7Y7yZ(4%in3)DPGFgCWSGuV52iL$Az>i|i^56?tWunqm!ah(WjJ!& zs+nJ5RRq9yi?k6Yl`te*57NzAOyYL*=^COqsMSS2oGq%0(NMgm`z#SD+*q8+d5-NE_J0vB$;2*>MzN2}7 z05-rBc7Oi>?4Oj22eqt8ikB(eYi002ovPDHLkV1nq<=x6`{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..290fc454200e653bb8498fa79720ca1701b69539 GIT binary patch literal 2051 zcmV+e2>kbnP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NJZAnByRA>e5S#N9UlRy9VcM_3AZCb=~(MV_J!(@kN@(7NY+P{rX5`&!?$uhC=HegKiykv(cYH zf0}cx(=cL(4jkBf0&{t)`A*rm1E{R5xq~tPddB!ua5~dzh-l7v|HufN-`2Kg$W0y$ zuFDAo_|J^7X>M{@xXd|wpYv#4bMwBl?m8;hr;^E3I2@i^Sh#Gbrs=!jcqPDRC2N@+ z$TNLDKHAfB>MJ*$;^Ly$7}JApGRs01R+W6buy}FtqQb(3-*$I*yCy@}NVx(P6>C@N zy8aQ2=7&FIGJd0Fad`SrHp-eWzBdQ z&Tn(d+-Nha(H*kPO2~(rR$U(sXY4~lZ2uOP2?e0Tcu00&7;Lv2c+zQaZyyeY){;I} zflUFxa|rkXXsUf`*b8hnvN;d@QPlOOj*dngyKb#9W*F;sI}%8?pTlD~XSnWxii$NW zd_LdTLK%Pt`uciib#``+3EuVWOG-*I^79KWAz*vM$6~RSEiHSGI{i|nlbX+`KO!@+ zVLzKJ@Z^_#kxv3{1j~ic?2Th8ZBiDP;|Ml{=N9Y1dF?-L%G6mC)%Wo2z~(5S zV6b{7fY{$OsKLLH%`++#LHRdB*FL8O*W|@lMX;=XqWYRfz6pacMA!(Ups80@AQ151 zB?~d`!Q=G=qr@*Ftv zP2<^IKdvloH<=qx7*8crFgP(f0!aF)nBx48ZbW?!2n3=xdC`r6PV^Jiw`VFC@Wt!B z2jF~$Jq-iKV!z3>JT{NYXes7I@cd~#5genXZpY|E^%K7MrNkH;(t*)03jukGOvhqu zRHmI{3uxHM_o64NPj(E0kNAA@i%G@kC?W3n-eyTZUcA6vKfa0qdqBac1b`i`0LG7s z!1V(*KFa`{DQh}0DkX&ax`LO5+~&#zjVmf@AH|Z^;d3_wC3loD<8U;}j|~l7?2>;c zJLk!C8Girf)7K{pqMiNvRa9`eJ=^yzsq+`9b;-Z?zK0MsA?wBDlpW{mNfu2DkfVd|ae<6pd{ z88d50%ubTyk(!FZ#F!8(0bs*?=d(Yg<4Pg44UtI1J_!Ka^p>z8Mm|XBMMw{ttR|05 zsmEWXq~UuO^yv4n-3X&iV4zGG9K3i8nY%0$00>0qi}y}l(2cNP z1EKxhRav>V3b%rv$P=OWD)hF=yri`a27^-qf$1l$antM^g zM-abVa9G->%@Wk}p)wm-vhkqysnK3wbd7>68-2RYmg9LhE{gx*xO6b+*5NpjI4H_d z$_FsVK7p`8y(1%k+}+mJro5Yy0x%7%DMj>0kWqP$jKRoTaJ1j=*IS`SK&{5lG&PMC zdfgidtwdk#!{t>s0ODHSZhg_Hu?n{jRY=|@aB<3+4VjN3f%l651)q9l4+sG%;{1JH z&{m%T!*2roNzf8_+85fh;|EXH8j*3DqqKaoO+LW2!IK7IaRW~>y99`kTX%O?XF);n zFjhgiBa(H@v9YDOxnWl_KQ*Vf_tZ~?g+(Yay0Z6fX5BYz_tNkTfF|Ic|0seufnhlU zvFbTt3MKUcHr0#Z*qbjQz4{1AI#f`wWInRK)Ts^NSHP=<{r8+QnNAr1DI$p81q+tc z`TYj|(GY(b<-lP-KC@nj@i(AyP2epM9QIqV61Zw-%a9PV^*u~($A5aBY;CQ_INj12 hNM|6Of&X&`{ss5H%Pjz_x;p>>002ovPDHLkV1h7ZwHW{a literal 0 HcmV?d00001 diff --git a/public/images/emails/social/github.png b/public/images/emails/social/github.png new file mode 100644 index 0000000000000000000000000000000000000000..8caa67b57e233a06773b1592b85d832bd5e2378d GIT binary patch literal 1791 zcmVPx#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NIX-PyuRA>e5SzBxrRT!SjZm$%D zw2JW(f|7z!@Pdh&06|DAHN7pV)MDU8CB8@``a+C^X!>T1V(=0$ilLM=>~6bQE^4xs zMEYPbNMhQcrpAhBwXL9~b$90Y{oS46pR+TwTj-Vtdy?7n-_HMk|GD*?Q$?9Avpw+t z?g2HI^cxzQ*J_&fst{^9?0?1AYZ%7iuCDfPa%rEeO$ksM%0=2bIl?Q{`034Od&V*2URn>Ss9B%(q*5$&7Ld{PIp}(T4%EK=9|0ELT z!dUF!A1*pu6tF1|Lf6$0R2ksOJQY|!(%86(dMcu-Vj1csNbFWp4%9;Rz{R~%kSi0rbM-PGhk<7K@EJWz)~4Vu;<`Gm@z0 zJf}Sq!0toG0=o!dT-ABUb}$9PCDwAU=AH>4Ysc<0GGhMVhs5C#&Tlekv$W@u>Hmj%gVIdIiF8jjwNH^ zu{{&Ou>Hslakg2(`FzrHEEx-r?U?|KPIE)_!Wz!!lXi*az`|pDCV;K%b8d*g3s2JZ zlFRZzQw)m5eal$5&NG40(F;8oI=aAHr6>^4-n5u})#(^TmVd%PSS((yby;BX0|Nu* z(xvwV(HrY58HRRQW#zp}U*Fj;E!i~IYC}WIPEAu^aQZY`j~zSW8PD@DM^0*MYfFN` zxpZ-{?*l34q8r1yBYmk!iLT+dv2i0VIC?9#Zu$QGeIjvjx!)1SHvz(gH=7lBv*|(a zmvD?YnF`3hf~-54R89o~<9*?9m`^!w?w~`V&@IVi(Gpx>@D8NZV46INk#=B#Yz%Kt ztMT^qwWG1bI2uVho2{>JUa9NaVf0XiEp#VviZr(3ov6!Ajd!3rMbY1f4v$xUMGP5+ zxuLVO{iL52=>oDreSJ#}-eV{Tk`sIT`xC9?sHUdnp`s$OAL3PV8^$ld9{wJ9*939_ zkAdx(m{7KKcenq<{CbiZ322%^0B%dQPlIP4Ax=S z*lWW8VVdT4&T|?dUxfdImJECnEw+x28?mEDJBBRzRO*c0qA7$?_z-~{$7$0Po1@XT zPq=fsc9xbF{AtMmXarZz`4;a|tGF^6@NLHphYwJP2^ZU(L{e|#&7-poTjUttH^o`* z0WsgHt7~#>rxO!H(hW^5|9sCr}bA2p4w+5K*DG=I%*gRJXq0VRjMHD+(01M zjUCB8mB!=oaZ67%Y#duH*X4`OSOZp|uC9)LygF|OsCO+HATGxL3C>^|>_c+&NHy<@ z?)p%!BVa+ndnI-x@sY(-jTwMvLiHuAb*vNZ4X(Q$v~L0gXn6Sivse%9*8^NGF|#Is zd7i}1(t;$m(JqsV)0wo%nm`(iKoW0{j3n+b45b-+H>Qr*hlJjmNw99QJQuN>aXjAf zH{^%X-=s+>v}v20?M%sX9RWW)gG|Y3CU%hAhSLtCT$yW5z%-TT5R8BfrfJ{UQ?>VX hHmjWNf!Q8V{sYVM9o@{U-Q55H002ovPDHLkV1hdJN>u;= literal 0 HcmV?d00001 diff --git a/public/images/emails/social/instagram.png b/public/images/emails/social/instagram.png new file mode 100644 index 0000000000000000000000000000000000000000..8140f2daf7890719460ce0fc71d1b9074a4400ba GIT binary patch literal 1327 zcmV+~1Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NGnMp)JRA>e5St?frkj(V6e$!v|Ix}y&(=$5l13eG? zn;sBJl#h&zi!&byAwMPXPsHF)#p3FaQ$s^H z9@*Rb?MD)(Oaj{);DWdAn1pm=jly)4jL4G_n^2`ucL<2pZ2((k_9AVt)=#T+l^f%{wHd+Dt?Od-^IS7gNlzd_*N zp(T?{2_7SRoOE_~mDs>Jhm3@=qJ8%SmDs}Qwxm>x=AeC*~{@JqfI|O{to0mv- zqTgeDiOE25;+@_GoHl$fmBRCtiUz*1?9F|k^Xhx~T3osV2DnG1udr4-`N5~7&(l+( zt@+1nlrPCRwE;<#<#NZy+CGUapEtDucuD!f@XTiOFY_^{qifN@_|I8gp3V^LRV+@nz`Lr%mwL+nP{R*!@|{@a$uV?qHn{;-J~f5 z--yW9u<*5(2B6WZF;deBeH%X44cgQ37MC5x!q-|FfG#ne7zZJ<=tI+~n?{6}^p5z-#@U{j(09xF`!-Zd|{C$yXny&tj05MNrb+hI2@@MV9!+FLW00Y1Ni~nNc l_Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NHf=NU{RA>e5Szl-zMHt`NyG;}S zKqLBKtwONzE;JV@_|}T?B~bA$*?)dv|Gr8HjoxR=L1AVxGWoEwl{(tlBe6y=*o!0R{ z#{(S?{FfeJD&eL%{j{d>PZ?wTR5%WtTj*DB+$cZ0wzhc7A2;~w*x2NluIsZzVcXgZ zg+lHdFKXQj=)i&~Pnax*XRNok>oYa4a4V)cmDP3j6{z;pqwAWIw~qnggfga0nr8My zII2c$QXahK9LF@S*F$^^;HT=5o6lpjrg?0jIi5F$xANSwZTqA*g{CPDSYBSZh#Z)& zCj>KdX(`9(sZ_R;SpP@hz;kyn7RwJ04|fY#&|alH=h{r6uuxF(Q5rx5mCCJ`&~3=X zNb1i=N3#zStXN!J=iGipUPtJM_U?V)eK*X1=BNDf@_gQp+Sh<$v3S$Ac@~k_7EEA3 zZ!D%SXxaplSJS*Ag`|6Vd^~Fk8oxe@^2FEI0757f=6^&O-=uM^vBRd>bwy3KT*SH{fWfT z%~I*w4+J}KVDJ)>cm_Ck@8Dp_M*8}aUmQGmDDCBW%VN`r;FmR?!ADik1xF{$iI`~` zYf$P)jgD_uD)!-GF@J?%lpFCl`xZkf!u%)9hw8TtDZjFkYe{*MB?+KukhVxC;}r5? zvxd#O@M_IxGMTE^i^cix@cMnXwoW5k~0A z6v}6Sb6L#YJ-4EIUhr$l03vpdcm9!J)U^x*74^0KAB7nL20QsPv`_ zd|1T=eq;l@Ya7A78iga{@m^wQP{&vr}^E zZCSSOB}pP|I&9ehmGX4LqVyhtm#<6GQF?20I3MmM7}ZS3mWE4`94U#`ci55ve#+Ac z>s@-YeTKmcvC@#t@NIlJ3V^}rN(PJFvTTFn-iBL zH?8+9lmS7N7owJy%BQiFTm>Bx_G~7TeT;Bak&-V-f|sffY(QA$9f7+Aop0&gBvLBf ztMEVuw5GfxH%RH-EK+_JG&LZK@B_(O2NIIF@?8sH{~=6RVHzNMb8FX-fTuQ9fstICyelm^gm9U%Tm4?_4% zyY8?g9KWMpgc0~;Uj02qAZ3H{e=%m!SMpBlc%b8fjtBmS9{3kk-OEPj#w5=G0000< KMNUMnLSTaKqvz8A literal 0 HcmV?d00001 diff --git a/public/images/emails/social/youtube.png b/public/images/emails/social/youtube.png new file mode 100644 index 0000000000000000000000000000000000000000..33509fcb8968d81cbc51c7f1e24b476b2635385b GIT binary patch literal 1107 zcmV-Z1g!gsP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NFy-7qtRA>e5S~PEmnSC!S!`lJ>}Es$CuNel4(CLbIe z?Ck03&2i3h&~6CcbByt>{rVifPLyGcZFpCOV8yMi&EneH+HSpZ>k=@{n}!hD4Cnk} zz0U2>3n6}TUYWA2cXrr(dWcdpjEQN^wOIh)9a7&`RTYDFqpvS}YGdQuN0?kt009D3 z@N9}UItntl@eq(W2_!yHJU$mfw_!}TXagWzu2lG(5Y9rWCJa#&h z(FZu!Cp1mm3&xydQg>1W+<^U2+UtEPRa?W@Dq}a`{V|#@|Eh3$h2s1O#@! z3~WtSR971suxR<@z#}d+9AZE(kT}u6h*g#q0e7jCdWLS3l;sr&5L^tesg7b*1fT(~ z^XfsLSA~QH$Em!nfuOKb0&QNqOf*&(EK}Wv#Sj(zv!!T?s(I-z59x> zCC3}TbqTSU<^&nQ!%F`F(x4Sl%t3&ici$JL+4;pV#;>+AQZs!bBY?pD6Z0(W=AR5B z|7K)l{H(t&x>95W>Olfxw>vucCJ=eR*jQlauh*|pee49PKzIg-#Nt*l5)kV_D@N~D zu@SJW-OI4;KZKR3R7*s<)#&K>_1d~-%dru_CDgVHvwM4@7f8$pf$@y4YrY*e*ydnO zj0C*N;b!GK!+SM{m&itT_`&=mjOm8aISJ>Mr*I_B`0KDFN{%D$2KCKpVk3YX8350Q z!x8h~T2sr*i@(F`8`s80ARN3{hrph+?ZwZHL)^b*j0EV!q6Bg9B+B!V9K8($qH`As zn*K&c04%%>0jrL=5Tud23k5Fa$Ov%m+=qk!T>f#%6wl&t7YUmFs!P3T=Ks+Qz|Kzj zOtW_aI&hRSW-7Y7yZ(4%in3)DPGFgCWSGuV52iL$Az>i|i^56?tWunqm!ah(WjJ!& zs+nJ5RRq9yi?k6Yl`te*57NzAOyYL*=^COqsMSS2oGq%0(NMgm`z#SD+*q8+d5-NE_J0vB$;2*>MzN2}7 z05-rBc7Oi>?4Oj22eqt8ikB(eYi002ovPDHLkV1nq<=x6`{ literal 0 HcmV?d00001 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) => {