trypost/app/Http/Controllers/App/PostController.php

375 lines
12 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Actions\Post\CreatePost;
use App\Actions\Post\DeletePost;
use App\Actions\Post\DuplicatePost;
use App\Actions\Post\SyncPostPlatforms;
use App\Actions\Post\UpdatePost;
use App\Actions\SocialAccount\ListPinterestBoards;
use App\Ai\Templates\AiContentTemplate;
use App\Ai\Templates\AiTemplateRegistry;
use App\Enums\Post\Action as PostAction;
use App\Enums\Post\CreatedVia;
use App\Enums\Post\Status as PostStatus;
use App\Enums\SocialAccount\Platform;
use App\Http\Requests\App\Post\StorePostRequest;
use App\Http\Requests\App\Post\UpdatePostRequest;
use App\Http\Resources\Api\PostResource;
2026-04-23 16:23:24 +00:00
use App\Http\Resources\App\PlatformConfigResource;
use App\Http\Resources\App\SocialAccountResource;
use App\Models\Post;
use App\Models\PostPlatform;
feat: complete create + publish post flow via MCP and REST API Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of a post — create with platform selection, attach media from URLs, schedule or publish immediately, and fetch engagement metrics — without touching the web UI. MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool, ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains status/search/limit filters. REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics, GET /api/posts/{post}/preview, GET /api/content-types. Also fixes a silent CreatePost::execute bug — the action validated platforms[] but ignored it, so REST callers never saw their selection persisted. Adds cross validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform) so a LinkedIn account can't be saddled with x_post, and rejects inactive social accounts during validation instead of failing silently downstream. Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both MCP tools and REST controllers so behaviour stays aligned. New Resources (PlatformContentTypesResource, PostMetricsResource, PostPreviewResource, PostMediaAttachResource) keep controllers free of inline model mapping. Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST (PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and the publish job (PublishToSocialPlatformTest). Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
use App\Services\Post\PostMetricsFetcher;
2026-04-23 16:23:24 +00:00
use App\Services\Social\TikTokCreatorInfo;
use App\Support\PostStatusRules;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class PostController extends Controller
{
public function index(Request $request, ?string $status = null): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
$query = $workspace->posts()
feat: proactive connection check for at-risk posts + SocialAccount name centralization (#256) * 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.
2026-08-09 14:10:39 +00:00
->with(['postPlatforms' => fn ($query) => $query->enabled()->with('socialAccount'), 'user', 'labels']);
if ($status) {
$query = match ($status) {
PostStatus::Draft->value => $query->draft(),
PostStatus::Scheduled->value => $query->scheduled(),
PostStatus::Published->value => $query->published(),
default => $query,
};
}
2026-03-31 00:18:07 +00:00
if ($search = $request->input('search')) {
$query->whereLike('content', "%{$search}%");
2026-03-31 00:18:07 +00:00
}
$labelIds = $request->collect('labels')
->filter(fn ($id) => is_string($id) && $id !== '')
->values()
->all();
$query->when($labelIds, fn ($q) => $q->whereHas(
'labels',
fn ($q) => $q->whereIn('workspace_labels.id', $labelIds),
));
return Inertia::render('posts/Index', [
'workspace' => $workspace,
2026-03-31 00:18:07 +00:00
'posts' => Inertia::scroll(fn () => $query->latest('scheduled_at')->paginate(config('app.pagination.default'))),
'currentStatus' => $status,
'labels' => $workspace->labels()->orderBy('name')->get(['id', 'name', 'color']),
2026-03-31 00:18:07 +00:00
'filters' => [
'search' => $request->input('search', ''),
'labels' => $labelIds,
2026-03-31 00:18:07 +00:00
],
]);
}
public function calendar(Request $request): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
$tz = 'UTC';
$view = $request->input('view', 'week');
$currentDay = $request->input('day')
? Carbon::parse($request->input('day'), $tz)->startOfDay()
: Carbon::now($tz)->startOfDay();
$weekStart = $request->input('week')
? Carbon::parse($request->input('week'), $tz)->startOfWeek()
: Carbon::now($tz)->startOfWeek();
$weekEnd = $weekStart->copy()->endOfWeek();
$monthDate = $request->input('month')
? Carbon::parse($request->input('month'), $tz)->startOfMonth()
: Carbon::now($tz)->startOfMonth();
$monthStart = $monthDate->copy()->startOfMonth()->startOfWeek();
$monthEnd = $monthDate->copy()->endOfMonth()->endOfWeek();
$rangeStart = match ($view) {
'day' => $currentDay,
'month' => $monthStart,
default => $weekStart,
};
$rangeEnd = match ($view) {
'day' => $currentDay->copy()->endOfDay(),
'month' => $monthEnd,
default => $weekEnd,
};
$posts = $workspace->posts()
feat: proactive connection check for at-risk posts + SocialAccount name centralization (#256) * 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.
2026-08-09 14:10:39 +00:00
->with(['postPlatforms' => fn ($query) => $query->enabled()->with('socialAccount')])
->whereBetween('scheduled_at', [$rangeStart->copy()->utc(), $rangeEnd->copy()->utc()])
->orderBy('scheduled_at')
->get()
->groupBy(fn ($post) => $post->scheduled_at?->setTimezone($tz)->format('Y-m-d'));
return Inertia::render('posts/Calendar', [
'workspace' => $workspace,
'posts' => $posts,
'currentDay' => $currentDay->format('Y-m-d'),
'currentWeekStart' => $weekStart->format('Y-m-d'),
'currentMonth' => $monthDate->format('Y-m-d'),
'view' => $view,
]);
}
public function create(Request $request): Response
{
$workspace = $request->user()->currentWorkspace;
$this->authorize('createPost', $workspace);
$registry = app(AiTemplateRegistry::class);
$templates = array_map(fn (AiContentTemplate $t) => [
'key' => $t->key(),
'name' => trans($t->name()),
'description' => trans($t->description()),
'preview' => $t->previewAsset(),
'needs_account' => $t->needsAccount(),
'supported_formats' => $t->supportedFormats(),
'applies_brand_visuals' => $t->appliesBrandVisuals(),
], $registry->all());
return Inertia::render('posts/Create', [
'date' => $request->query('date'),
'socialAccounts' => SocialAccountResource::collection(
$workspace->socialAccounts()->active()->get()
),
'templates' => $templates,
]);
}
public function store(StorePostRequest $request): RedirectResponse|\Symfony\Component\HttpFoundation\Response
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('createPost', $workspace);
2026-03-31 00:18:07 +00:00
$socialAccounts = $workspace->socialAccounts()->active()->get();
if ($socialAccounts->isEmpty()) {
session()->flash('flash.banner', __('posts.flash.connect_first'));
session()->flash('flash.bannerStyle', 'danger');
return $request->user()->can('manageAccounts', $workspace)
? redirect()->route('app.accounts')
: redirect()->route('app.calendar');
}
$post = CreatePost::execute($workspace, $request->user(), [
'date' => $request->input('date'),
'media' => $request->input('media', []),
'created_via' => CreatedVia::Web,
]);
return Inertia::location(route('app.posts.edit', $post));
}
public function platformMetrics(Request $request, Post $post, PostPlatform $postPlatform): JsonResponse
{
$this->authorize('view', $post);
if ($postPlatform->post_id !== $post->id) {
abort(404);
}
feat: complete create + publish post flow via MCP and REST API Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of a post — create with platform selection, attach media from URLs, schedule or publish immediately, and fetch engagement metrics — without touching the web UI. MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool, ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains status/search/limit filters. REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics, GET /api/posts/{post}/preview, GET /api/content-types. Also fixes a silent CreatePost::execute bug — the action validated platforms[] but ignored it, so REST callers never saw their selection persisted. Adds cross validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform) so a LinkedIn account can't be saddled with x_post, and rejects inactive social accounts during validation instead of failing silently downstream. Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both MCP tools and REST controllers so behaviour stays aligned. New Resources (PlatformContentTypesResource, PostMetricsResource, PostPreviewResource, PostMediaAttachResource) keep controllers free of inline model mapping. Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST (PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and the publish job (PublishToSocialPlatformTest). Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
return response()->json(app(PostMetricsFetcher::class)->forPlatform($postPlatform));
}
public function show(Request $request, Post $post): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $post);
if (in_array($post->status, [PostStatus::Draft, PostStatus::Scheduled], true)) {
return redirect()->route('app.posts.edit', $post);
}
$post->load(['postPlatforms.socialAccount', 'labels']);
return Inertia::render('posts/Show', [
'workspace' => $workspace,
'post' => (new PostResource($post))->resolve(),
]);
}
public function edit(Request $request, Post $post): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $post);
if (PostStatusRules::blocksEditing($post)) {
return redirect()->route('app.posts.show', $post);
}
if ($request->user()->can('update', $post)) {
SyncPostPlatforms::execute($post);
}
$post->load(['postPlatforms.socialAccount', 'labels']);
2026-03-31 00:18:07 +00:00
$socialAccounts = $workspace->socialAccounts()->active()->get();
$labels = $workspace->labels;
$signatures = $workspace->signatures;
$platformConfigs = $socialAccounts->mapWithKeys(fn ($account) => [
2026-04-23 16:23:24 +00:00
$account->id => new PlatformConfigResource($account),
]);
$pinterestBoards = $socialAccounts
->where('platform', Platform::Pinterest)
->mapWithKeys(fn ($account) => [
$account->id => rescue(
fn () => ListPinterestBoards::execute($account),
['boards' => [], 'truncated' => false],
report: false,
),
]);
$tiktokCreatorInfos = $socialAccounts
->where('platform', Platform::TikTok)
->mapWithKeys(fn ($account) => [
$account->id => rescue(
fn () => app(TikTokCreatorInfo::class)->fetch($account),
null,
report: false,
),
])
->filter();
2026-04-23 16:23:24 +00:00
return Inertia::render('posts/Edit', [
'workspace' => $workspace,
'post' => $post,
'socialAccounts' => $socialAccounts,
'platformConfigs' => $platformConfigs,
'pinterestBoards' => $pinterestBoards,
'tiktokCreatorInfos' => $tiktokCreatorInfos,
'labels' => $labels,
'signatures' => $signatures,
'authUserId' => $request->user()->id,
]);
}
public function update(UpdatePostRequest $request, Post $post): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('update', $post);
$result = UpdatePost::execute($workspace, $post, $request->validated());
$action = data_get($result, 'action');
if ($action === PostAction::Finalized) {
session()->flash('flash.banner', __('posts.flash.cannot_edit_finalized'));
session()->flash('flash.bannerStyle', 'danger');
return back();
}
if ($action === PostAction::Publishing) {
return redirect()->route('app.posts.show', $post);
}
if ($action === PostAction::Scheduled) {
session()->flash('flash.banner', __('posts.flash.scheduled'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.posts.show', $post);
}
return back();
}
public function destroy(Request $request, Post $post): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('delete', $post);
if (PostStatusRules::blocksDeletion($post)) {
session()->flash('flash.banner', __('posts.flash.cannot_delete_published'));
session()->flash('flash.bannerStyle', 'danger');
return back();
}
DeletePost::execute($post);
session()->flash('flash.banner', __('posts.flash.deleted'));
session()->flash('flash.bannerStyle', 'success');
$allowedRedirects = ['app.posts.index', 'app.calendar'];
if ($redirect = $request->input('redirect')) {
if (in_array($redirect, $allowedRedirects)) {
return redirect()->route($redirect);
}
}
return redirect()->route('app.posts.index');
}
public function duplicate(Request $request, Post $post): RedirectResponse
{
$this->authorize('duplicate', $post);
$post->load(['postPlatforms', 'labels']);
$copy = DuplicatePost::execute($post, $request->user());
session()->flash('flash.banner', __('posts.flash.duplicated'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.posts.edit', $copy);
}
}