* 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.
* Wire onboarding activation into Account, observers, and shared Inertia data
Add onboarding casts/hasFinishedOnboarding, AccessToken ObservedBy,
Platform::connectableOptions, Post/SocialAccount onboarding broadcast hooks,
and lazy onboardingResidual share + SharedData types.
* Register onboarding routes and post-checkout activation redirects.
Wire billing processing and the sidebar checklist so owners land on
activation after subscribe, with locale sidebar/uk onboarding strings.
* Align MCP grant usability with onboarding activation checks
Unbound MCP tokens fall back to the user's current workspace and require
createPost so viewer/unscoped grants neither unlock the checklist nor
broadcast onboarding status.
* Require bound MCP workspace for onboarding activation.
Drop current-workspace fallback from usable MCP grants so checklist
detection and broadcasts match Passport token scoping; viewers still
cannot unlock the MCP step.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden onboarding review findings and tighten locale strings.
Fix Welcome/Persona/TrackPost suites broken by the activation route reuse
and PostObserver analytics side effects, restore Echo poll fallbacks,
reject unbound MCP grants in tests, and drop unused onboarding.mcp keys.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Remove unused sidebar and MCP authorization locale keys.
Drop dead sidebar menu/theme strings (including the overwritten
workspace label and api_keys nav entry) and unused MCP authorize
app_title/approving copy across all locales.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix SetLocale crashing on Passport Symfony OAuth responses.
OAuth errors return a raw Symfony Response without withCookie(); attach
the default locale cookie via headers so authorize no longer 500s.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Prompt OAuth guests to log in before rejecting unknown clients.
MCP Inspector often reuses a stale client_id; validateAuthorizationRequest
was returning invalid_client JSON before the login redirect. Guests now
hit /login first, then client validation runs after authentication.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Render Inertia OAuth authorize errors for browser logins.
After login, Inertia follows the intended authorize URL; raw invalid_client
JSON broke that visit. HTML/Inertia requests now get mcp/AuthorizeError
while API JSON clients still receive the OAuth error payload.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Detect Inertia OAuth error pages via Request::inertia().
Use the framework helper so post-login authorize failures keep returning
an Inertia page instead of raw OAuth JSON.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify OAuth authorize error page detection to expectsJson.
Drop the X-Inertia header sniff; browser and Inertia visits already do
not expectsJson, while API clients still receive the OAuth JSON payload.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Share MCP authorize layout and drop the error close button.
Keep authorize and authorize-error on the same centered card shell instead of the auth split layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding activation for reviewability and safety.
Use an exists-based MCP check, keep GETs read-only, move sync into
syncAndNotify, clear MCP skips on connect, restrict complete to owners,
and share Echo/poll via one composable.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move MCP OAuth authorize UX out of the onboarding PR.
Keep the activation checklist focused; OAuth guest/error-page work now
lives on fix/mcp-oauth-authorize-ux.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix corrupted French MCP locale after OAuth key cleanup.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Restore MCP OAuth authorize UX onto the onboarding branch.
Keep authorize error page, guest login-before-client validation, and
SetLocale Symfony cookie fix in #250.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix OAuth prompt=none redirects and harden onboarding tests.
Keep login_required/consent_required as redirects instead of Inertia,
add regression coverage for owner-only activation, require invite email
confirmation, and align MCP connected apps with the sessions list UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding guards and dedupe viewed analytics.
Introduce isOnboardingOpen / belongsToAccount helpers, collapse
duplicated sync/dispatch paths, and capture onboarding.viewed once
per account.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding event, observers, and status helpers.
Tighten Account onboarding predicates, drop nullable broadcast/dispatch
APIs, and collapse repeated observer/controller guards.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Treat in-app users as always having an account.
Add resolveAccount(), tighten belongsToAccount to string ids, and fold
guest residual handling into ResolveOnboardingStatus.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename onboarding residual share test to progress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding status and rename residual to progress.
Use accountOrFail, extract MCP onboarding scope, auto-leave the ready
screen, and send non-onboarding checkout back to accounts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Extract HasAccount and prefer data_get in onboarding flows.
Move account helpers off User, drop nullable sidebarProgress, and
read OAuth/onboarding payloads with data_get.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding checks and extract HasOnboarding.
Use Eloquent + policies for MCP/backfill paths, and move account
onboarding helpers into a dedicated trait.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add trait tests and tidy onboarding imports.
Cover HasAccount and HasOnboarding under Models/Traits, prefer filled() for checkout session ids, and import Throwable instead of FQCN.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify checkout session_id and OAuth error props.
Read session_id via request->string(), and take OAuth error details from the League exception instead of decoding the response body.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify PostObserver onboarding notify path.
Share one otherPosts check for first-create and last-delete instead of separate callbacks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use post author as onboarding sync actor.
Drop Auth::user() preference in PostObserver; checklist sync attributes to $post->user.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify SocialAccountObserver and OAuth authorize flow.
Share create/delete onboarding notify, drop Auth actor fallback to owner, and inline Passport Inertia error handling.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use lazy Inertia props for onboarding partial reloads.
Drop partial-header branching; wrap page props in closures and always redirect completed/dismissed accounts to the calendar.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Defer sidebar onboarding progress and stamp completion as owner-only.
Skip the MCP checklist work on full Inertia visits via deferred shared props,
early-exit token scans, and keep account completion stamps owner-gated.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify deferred onboarding progress share via canShowProgress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add User firstName for shared auth and simplify onboarding page.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move User firstName coverage into UserTest.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use first_name directly without empty-name fallbacks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Resolve onboarding sample prompt on the frontend via i18n.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Stamp onboarding completion via the account owner after teammate unlocks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Count only the account owner MCP grant toward onboarding activation.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix OAuth consent auth-token mismatch for mid-activation owners.
Skip deferred onboardingProgress on Passport authorize so Inertia does not
rotate the session authToken, cover happy and stale-token paths in tests,
and polish MCP setup copy plus sidebar/onboarding layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Keep users on onboarding after activation completes.
Stamp completion and re-render the finished checklist instead of
redirecting to the calendar so owners can review the done state.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Clarify Passport consent-view opt-out and guard app-route deferral.
Rename the authorize-only route check and assert onboardingProgress still
defers on calendar, onboarding, and MCP settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden onboarding completion and MCP consent workspace binding.
Reject OAuth approve without a workspace, retry auto-complete until
stamped, send dismissed complete straight to calendar, and cover the
device consent defer opt-out.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Enable activation checklist for self-hosted installs.
Remove the self-hosted onboarding redirects, keep the SaaS-only dismiss backfill, and cover subscription-less owners plus skip/complete destinations.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add GitHub, Hacker News, and directories referral sources.
Expand the welcome referral step with open-source and directory discovery channels.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refine welcome referral sources and labels.
Split Instagram/Threads, add Founder, and shorten Google, GitHub, AI, and blog option labels.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Sort accounts platforms alphabetically and drop connect hover plus.
Reuse connectableOptions for the accounts index and remove the unused plus badge on disconnected cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Centralize PostHog once-capture so disabled installs don't burn dedupe keys.
Move isEnabled + Cache::add into PostHogService::captureOnce and route onboarding viewed/step events through it.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding backfill to complete every existing open account.
Drop self-hosted and subscription filters; down clears completed_at again.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop PostHog captureOnce and use plain capture for onboarding.
Remove cache-based event dedupe; callers rely on PostHogService::capture gating.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* Scope MCP OAuth tokens to user + workspace
Bind authorization-code grants to the authorizing workspace (via auth codes),
inherit workspace on refresh, resolve MCP/API requests from the token instead
of current_workspace_id, backfill existing grants, and revoke workspace tokens
when a member is removed.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add multi-workspace MCP OAuth coverage
Cover coexistence of the same client across workspaces, settings
list/disconnect scoped to the current workspace, and API key
controllers excluding workspace-bound MCP grants.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use constrained foreignUuid for oauth_auth_codes.workspace_id
Match the project's UUID foreign-key convention instead of a separate
foreign() call.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Localize the MCP OAuth authorize consent screen
Wire authorize.blade.php to mcp.* translation keys (including the
workspace scope copy) and cover pt-BR rendering.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix invalid Mockery import in bind workspace test
CI treats the non-compound `use Mockery` as an ErrorException and
aborts the whole parallel suite.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Inline MCP OAuth workspace backfill into the migration
Move the one-shot backfill out of a dedicated Action and wrap it in an
explicit transaction so a failure rolls back partial binds/revokes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Nest MCP authorize i18n keys and test backfill rollback
Group consent-screen copy under mcp.authorize.*, and assert the
workspace backfill migration rolls back binds when it fails before
commit.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Hardcode TryPost in the MCP authorize page title
Drop the config('app.name') interpolation from the consent screen title.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add workspace picker to MCP OAuth consent screen
Let users choose which workspace to bind at authorize time instead of
always using current_workspace_id; silent re-consent still falls back.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Tighten MCP authorize workspace select spacing
Match NativeSelect styling and give the label, control, and helper text room to breathe.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Convert MCP OAuth consent screen to Inertia Vue
Reuse AuthCardLayout, Button, and NativeSelect so the authorize page
matches the app UI. Keep native form posts so Passport's external
redirect still works for MCP client popups.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Polish MCP authorize layout with logo and workspace combobox
Drop the shield and AuthCardLayout double-logo, put TryPost branding
at the top, and reuse the app Combobox pattern for workspace search.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Align MCP OAuth workspace backfill with mcpOAuth scope
Reuse AccessToken::mcpOAuth() so the migration only touches mcp:use
grants on non-PAT clients, matching the rest of the codebase.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Tighten MCP OAuth workspace backfill heuristics
Only touch connected MCP sessions, bind a sole membership or a valid
current workspace, and revoke ambiguous multi-workspace grants instead
of guessing the oldest workspace.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop Passport connection override from auth code migration
Always use the app default database connection from .env.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Bind MCP OAuth workspace in AccessTokenRepository
Replace the AccessTokenCreated listener with the same Passport repository
override pattern used for auth codes, so workspace_id is set at persist.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify AccessTokenRepository workspace binding
Drop redundant string casts and the oldest-workspace fallback; keep a
small ownedWorkspace/payloadId helper surface instead.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Extract Passport MCP authorization view from AppServiceProvider
Keep configurePassport thin by moving the Inertia consent props into an
invokable App\Passport\AuthorizationView class.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify AuthorizationView and cover it with direct tests
Use collection higher-order mapping for workspaces/scopes and add focused
tests for current-workspace selection and empty-user props.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename BindWorkspaceToAccessTokenTest after listener removal
The suite now covers AuthCodeRepository and AccessTokenRepository
workspace binding, not an AccessTokenCreated listener.
* Fail closed when auth code has no bindable workspace
Authorization-code grants no longer fall back to the user's current
workspace, so a token cannot be minted for a different tenant than consent.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Retrigger CI after GitHub Actions infrastructure failures
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: retrigger CI
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: harden MCP OAuth workspace binding on refresh and backfill
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: always show MCP OAuth consent to pick a workspace
Disable Passport silent re-consent and require an explicit workspace_id
from the consent form, with Passport wiring moved to its own provider.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: sort MCP connected clients by last used
Show most recently used OAuth connections first on the workspace MCP settings page.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add workspace MCP settings and token access controls.
Ship MCP settings UI, OAuth revoke/list helpers, Passport deploy wiring,
and workspace.token:mcp gating so assistants can connect without pulling
in welcome/onboarding from the parent epic.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Type MCP client config shapes instead of string checks.
Encode http/config-root on each advanced client and tighten primary
client ids so snippet generation does not branch on magic strings.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Polish MCP settings follow-ups from review.
Translate Ukrainian MCP copy, deep-link ChatGPT into connector
creation, drop an unused asset and revoke arg, and assert PATs are
rejected on the MCP endpoint.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden MCP connected clients, revoke scope, and OAuth consent.
List recoverable sessions with live refresh tokens, revoke only PATs,
throttle registration alone, and block viewers from authorizing MCP.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify MCP OAuth route throttling to a single middleware group.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Allow workspace viewers read-only MCP access with web policy writes.
Mirror the web app: MCP connects on view + OAuth mcp:use, write tools
enforce createPost/update/delete/manageAccounts/manageTeam, and demotion
to Viewer keeps grants. Cover role denials, consent, and disconnect.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden MCP tool authz with shared workspace helpers.
Route ApiKey tools through AuthorizesMcpTool, fail closed on null user
or policy argument, and resolve the current workspace before mutating.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant string casts on validated request data.
Enum::from and validated() fields are already strings, so the casts
add noise without changing behavior.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Show only the current user's MCP connections in settings.
Match API keys privacy: list and disconnect your own OAuth clients,
not teammates' across the account.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Cover LoadWorkspaceFromToken gaps and harden AuthorizesMcpTool tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant is_string guard before UpdatePostTool find.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refactor AppSidebar to always show MCP link and simplify route middleware definition in ai.php. The MCP link is now consistently displayed regardless of the current workspace state, and the route middleware syntax has been streamlined.
* Refresh MCP connected clients with Inertia usePoll.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Bump laravel/mcp to 0.9.1 and add the TryPost server icon.
Requires laravel/boost 2.5 for the Icon attribute; expose images/trypost/icon.png on TryPostServer.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop no-op ReflectionClass import in TryPostServerTest.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* Allow owners and admins to delete workspaces from settings.
Expose a danger zone with name confirmation, sync Stripe quantity on SaaS, and skip billing constraints in self-hosted mode.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant canDelete prop from workspace settings.
The settings page is already gated by update (owner/admin), which matches delete.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Extract workspace delete danger zone into DeleteWorkspace component.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Clarify workspace delete billing copy across locales.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Match workspace delete card to the delete-account settings pattern.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden workspace and account deletion around shared members.
Enforce owner-only workspace creation, rehome stranded members to a personal account, warn about member access loss, and clarify the only-workspace SaaS exit paths.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden workspace delete: owner-only billing impact and safer member rehome.
Restrict delete to account owners, rehome stranded members transactionally with account-scoped fallbacks, and clean up the danger-zone UI/copy.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix workspace delete review findings.
Prune pending invites and media on delete, lock the account for the
last-workspace guard, fall back to account-owned workspaces for owners,
redirect self-hosted last deletes to create, cancel Stripe after local
cleanup, align personal-account trials, and gate Index create for owners.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Harden invite accept and account delete edge cases.
Stop invite accept from demoting existing roles, expire dead invites on
show, preserve flash by avoiding calendar bounces, move media file I/O
outside locked delete transactions, and finish account deletion even if
Stripe cancel fails.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Fix remaining invite redirect and media cleanup edge cases.
Distinguish already-accepted invites from gone workspaces, rehome
members removed from their last shared workspace, capture media paths
inside the delete lock, extract orphaned-file cleanup, and use Wayfinder
for the expired-invite home link.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Fix invite current-workspace and account-delete edge cases.
Switch invitees onto an invite-account workspace when accepting, prefer
same-account fallbacks when removing members, abort account deletion if
Stripe cancel fails, and clear avatar media on profile delete.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Fix Stripe-failure media leak and invite cross-account redirect.
Flush workspace media files before billing cancel can abort account
delete, and rehome stranded non-owners before picking an invite redirect
fallback so current workspace never points across accounts.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Never set cross-account current workspace on member rehome.
Keep RemoveMember and account-delete member fallbacks same-account
only, clarify the billing-failure flash that workspaces were already
removed, and assert storage deletion in media cleanup tests.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Sync Stripe workspace quantity when account delete billing fails.
After local workspaces are wiped, a stuck cancelNow must still drop
seat quantity so the subscription cannot keep billing the old count.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Prune account invites when owner delete wipes workspaces.
Pending and accepted invites are removed with the workspaces so a
Stripe cancel failure cannot leave unique email/account rows that block
re-invites to a gutted account.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Extract DeleteWorkspaceMedia to purge workspace media rows.
Call sites capture returned paths inside the lock and still flush
orphaned storage files after commit via DeleteOrphanedMediaFiles.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Redirect to calendar after deleting a workspace with a fallback.
When DeleteWorkspace already sets another current workspace, sending
the owner to the workspace picker is unnecessary — take them back into
the app instead.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Use Wayfinder for invite redirect and logo home links.
Replace hardcoded /invites/{id} and / hrefs in AcceptInvite with
show.url() and home() route helpers.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Use Wayfinder home() for AcceptInvite logo link.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Extract AcceptInvite title and description into computeds.
Keeps the expired/active copy logic out of the template and matches
the existing trans() pattern used elsewhere.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Fix lazy-loading crash when deleting a workspace.
isAccountOwner() no longer touches the account relation unless it is
already loaded, and delete/rehome queries eager-load account when they
need ownership checks under Model::shouldBeStrict().
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Avoid isAccountOwner during workspace delete fallback.
Compare against the already-loaded account owner_id so current-workspace
reassignment cannot touch the account relation under shouldBeStrict().
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Add tests for DeleteWorkspace functionality
Introduce comprehensive tests for the DeleteWorkspace action, covering scenarios such as deleting stranded members, handling multiple workspaces, restoring members with personal workspaces, and managing invites. Ensure that workspace media files are deleted and verify behavior when the last workspace is blocked by SaaS settings. This enhances the reliability of workspace deletion processes and ensures proper account management during deletions.
* Refactor member removal process to delete or restore stranded members
Updated the RemoveMember action to utilize the new DeleteOrRestoreStrandedMember class, which handles the deletion of stranded members or restoration to personal accounts. This change improves the management of user accounts when members are removed from workspaces, ensuring that non-owner members are properly handled based on their account status. Additionally, tests have been updated to reflect these changes, ensuring that the functionality works as intended.
* Enhance member removal and media management during account deletion
Updated the RemoveMember action to collect media paths for orphaned files when removing members. Integrated the DeleteOrphanedMediaFiles action to ensure that any media associated with deleted users is properly purged. Additionally, refactored the DeleteOrRestoreStrandedMember class to return media paths for cleanup, improving overall resource management during user account deletions. This change ensures that all orphaned media files are handled efficiently, maintaining system integrity.
* Enhance user account deletion process with force delete option
Updated the DeleteOrRestoreStrandedMember class to include a forceDelete parameter, allowing for immediate deletion of members and their associated personal accounts and workspaces. This change ensures that when an account is forcefully deleted, all remnants of the user's data are purged, improving data integrity and resource management. Additionally, updated related methods and tests to accommodate this new functionality, ensuring comprehensive coverage and correct behavior during account deletions.
* Extract shared delete/invite actions out of fat controllers.
Centralize workspace/account/user teardown and invite accept/decline so ProfileController and AcceptInviteController stay thin HTTP wrappers.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden delete/invite invariants and replace invite string outcomes.
Block cross-account workspace listing/switching, cancel Stripe on owned accounts before purge, lock RemoveMember, fold owner fallback into ReassignCurrentWorkspace, and type invite results with an enum.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Polish delete/invite teardown APIs and cancel Stripe on empty accounts.
Extract DeleteEmptyOwnedAccounts, rename settle-after-invite, and expose
clearer stranded-member entry points so cancel never races the invite lock.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Finish stranded teardown craft: settle outside locks, clearer names.
Defer empty-account Stripe cancel until after the account lock, rename
stranded handling to SettleStrandedMember, and extract AccountsRequiringCancel.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden multi-account Stripe cancel order and typed stranded settlements.
Cancel member personals before the shared account, introduce CancelAccounts
and StrandedSettlement::flush so partial Stripe failures leave billing intact.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Reuse strandedMemberOnSharedAccount across delete/invite feature tests.
Expand the Pest helper for shared workspaces and owner injection so
stranded-member fixtures stop being hand-rolled in every suite.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Lock the account row during owner account teardown.
Serialize DeleteAccount with DeleteWorkspace/RemoveMember so concurrent
stranded restores cannot move members off the account before force-delete.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop personal-account restore when leaving a shared account.
Invitees abandon their previous personal account on accept, and stranded
members are always deleted — matching the real product flow.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Close the account model and consolidate teardown actions.
Block invites to emails that already belong to a registered user — accounts
are closed (one user, one account), so members never own a personal account.
This removes the whole leftover/restore surface.
Consolidate: fold AccountsRequiringCancel/CancelAccounts into
CancelAccountSubscription, drop DeleteEmptyOwnedAccounts/DeleteOwnedAccount/
PurgeOwnedAccounts, and fold DeleteAccount into DeleteUser. 23 -> 15 new
action files.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Remove orphaned members.errors.already_member translation key.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Block invitees from creating a workspace on the invite shell.
A pending invitee could open workspaces/create (outside EnsureHasWorkspace)
and add a workspace (then billing) on their empty signup shell before accept.
Accept only tears down an empty shell, so this left an abandoned, billable
account. Deny create/store while an invite is pending — the invitee joins via
the invite instead.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Tighten stranded-member fixtures to the closed-account model.
Drop the member's empty signup shell in strandedMemberOnSharedAccount and the
billing-abort profile test so the setup matches what accept actually leaves
(member owns nothing). Remove the never-overridden attachOwner param.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Bind invite registration to the invited email.
The register form shows the invited email as read-only when an invite id is
present, and store() rejects a different email for a valid invite. Also fixes
a latent bug: EnsureRegistrationEnabled only read the invite id from the query
string, so the self-hosted invite registration POST always 404'd.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move register validation into RegisterRequest.
Inline $request->validate() and the invite-email check move into
App\Http\Requests\App\Auth\RegisterRequest (withValidator). Invite detection
no longer sniffs a /invites/ redirect string — it resolves the invite id
directly; the invite registration test now uses a real invite.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Stream signed uploads through addMediaFromPath, return per-type max_bytes, harden Pinterest/Discord listing errors and pagination, and keep frontend duration fallbacks when Inertia once-props have not synced.
Co-authored-by: Cursor <cursoragent@cursor.com>
Persist whether a post was created through web, MCP, API, or automation so we can attribute entry points without guessing from request context.
Co-authored-by: Cursor <cursoragent@cursor.com>
Keep the image-normalize vs binary-stream split, but move each path into a focused private method so addMediaFromPath stays linear.
Co-authored-by: Cursor <cursoragent@cursor.com>
The last chunk was reassembling the full file locally and pushing ~185MB to R2 in one request (53s+). Videos/PDFs now upload each chunk as an S3 multipart part so finalize only completes the upload.
Co-authored-by: Cursor <cursoragent@cursor.com>
Unicode filename encoding was correct, but large videos still died on the last chunk: the whole file was loaded into memory and uploaded to R2 via Guzzle within PHP-FPM's 30s limit. Stream non-images with writeStream and lift the time limit on finalize.
Co-authored-by: Cursor <cursoragent@cursor.com>
Adds a single-select referral-source step between the goals and connect
steps of onboarding. The choice is stored on users.referral_source and
mirrored to PostHog, mirroring the existing persona and goals steps.
- ReferralSource enum (12 sources) + nullable users.referral_source column
- referralSource()/storeReferralSource() controller actions with the same
self-hosted, subscribed, persona and goals guards as the sibling steps
- connect() now requires a referral source before rendering
- Single-select ReferralSource.vue page mirroring the goals step
- Localized across all 15 locales
Cold review caught a regression from the two previous commits. Instagram and
Threads use long-lived tokens refreshed by EXTENDING the access_token itself
(grant_type=ig_refresh_token / th_refresh_token) — they have no separate
refresh_token and CANNOT be refreshed once expired. The anti-over-rotation rule
("only refresh a token once it's actually expired") is right for rotating
single-use refresh_token platforms but wrong for these: it left IG/Threads
tokens to lapse, after which the extend call fails and the account disconnects
(~every 60 days).
Gate the anti-rotation on the platform's refresh model:
- Platform::extendsAccessTokenOnRefresh() — true for Instagram/Threads.
- SocialAccount::needsProactiveTokenRefresh() — expired for rotating platforms,
OR expiring-soon for extension platforms (restores isTokenExpiringSoon).
- RefreshSocialToken extends (refreshToken) extension-model tokens while still
valid, and verifies (access-token-first) rotating ones.
- All 23 publisher/analytics pre-checks now use needsProactiveTokenRefresh().
Tests: proactive job extends a still-valid Instagram token; a model test covers
the rotating-vs-extension branching; existing X/LinkedIn anti-rotation tests
are unchanged.
Refs #126
Every publisher and analytics service proactively refreshed the token when
it was expired OR merely "expiring soon" (within 15 min), calling
refreshToken() directly. For X (and other single-use-refresh providers)
that rotated a perfectly valid access_token whenever an operation ran in the
token's final 15 minutes — the same needless rotation that breaks the
refresh_token chain and disconnects accounts.
Narrow every pre-check to refresh only when the token is actually expired. A
still-valid token is used as-is; if it expires mid-operation the existing
reactive retry (PublishToSocialPlatform) refreshes and retries.
- Drop `|| is_token_expiring_soon` from all 23 publisher/analytics pre-checks.
- Remove the now-unused `isTokenExpiringSoon` accessor (no references remain
anywhere in the repo).
- The reactive retry path (AbstractLinkedInPublisher::retryWithRefresh) and
the expired-token path are unchanged.
Refs #126
Cold-review follow-ups on the PR:
- Trim the oversized docblocks/inline comments added across the API controller,
MediaAttacher, Post, the publish job, and the X publisher to one line (keeping
the @param/@return array-shape annotations).
- XPublisher::chunkedUpload now accepts ?string $mediaCategory and only sends
media_category when present — getMediaCategory() can return null, so the strict
string param was a latent TypeError (unreachable on X today, removed anyway).
- Fix MediaAttacher docblocks: the file imports Type as MediaType, so the
@param array<Type> annotations didn't resolve — now array<MediaType>.
- Tests: cover the failed() job hook genericizing a raw error, and X failing
cleanly (XPublishException) when media can't be downloaded.
The public REST API accepted inline post media as a free-form array and stored
it verbatim, so a client could create/update a post whose media was a bare
external URL we never hosted. Publishing then depended on that third-party URL
staying alive — when it 404'd (e.g. an image proxy), the post failed across
platforms (Facebook 'unsupported media type', X 'HTTP 404', Instagram 'could
not fetch media').
Inline media URLs on create/update now go through the same download + MIME-
validate + host path as the attach-from-url endpoint (MediaAttacher), so the
stored media always points at our own storage. Items already hosted (carrying a
path) pass through untouched. If any URL can't be fetched the request is
rejected with 422 and nothing is persisted, so a post is never created with
broken media. MCP and the web flow were already safe and are unchanged.
- MediaAttacher: extract fetchToWorkspace() + add resolveInlineMedia()
- Post::allowedMediaTypesFor() so the create flow can compute allowed types
without a persisted post
- API Store/UpdatePostRequest: media.* item rules (mirroring the web; prevents
validated() from stripping hosted-item keys)
- PostController store()/update(): host external media before persisting
After picking who they are, users now pick what they want to achieve with
TryPost. A multi-select goal step (12 options + an exclusive "just exploring"
and "something else") sits between the persona step and connect, mirroring the
persona screen's style.
The goals persist to a json column on users and are mirrored to PostHog on
identify (onboarding_goals array plus a boolean per goal), so campaigns can be
cross-tabbed against the intent they actually attracted. connect now requires
both a persona and at least one goal; persona store advances to the goal step.
Options are grounded in TryPost's real capabilities (publishing, AI content,
brand voice, automation via API/MCP, collaboration, analytics) and copy is
localized in en/es/pt-BR.
Media-type detection was duplicated across Media, MediaItem (byte-identical copies), HasMedia::getMediaType, the ContentTypeCompatibleWithMedia rule, and six publishers — each hardcoding MIME prefixes and divergent extension lists (Media's video list even had avi/webm/mkv, contradicting the Type enum's mp4/mov).
Add classify(), fromExtension(), and isGif() to Media\Type as the single source for 'what kind is this?' (broad classification), distinct from fromMime()/allowedMimeTypes() (the strict upload allow-list). Every detector now delegates to the enum; the broad extension lists and MIME prefixes live only there. Behavior-preserving (full suite green); adds direct tests for classify/fromExtension/isGif.
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
Credit allotment is now derived directly from BillingCycle::for($account)->creditAllotment()
instead of a cached Pennant feature, removing the dynamic cache-invalidation footgun
(forgetPlanFeatureCache) that had to be called from every subscription/workspace mutation.
Pricing
- Bill per workspace ($12/mo or $120/yr each); Stripe quantity tracks the
workspace count and syncs on workspace create/delete.
- 2,500 AI credits per workspace, pooled at the account level; monthly reset
on the billing anniversary, annual granted upfront (no rollover).
- One social account per network per workspace; remove all count-based limits
(workspace/social/member) and the legacy plan tiers (single Workspace plan).
Onboarding (cloud only: SELF_HOSTED=false + PostHog)
- Replace the /subscribe plan picker with /onboarding persona selection
(Creator/Freelancer/Startup/Agency/Small business/Other), saved on the user
(users.persona) and mirrored to PostHog, then Stripe Checkout on the monthly
price. 8-day trial so Stripe displays 7.
Billing screen
- Remove the Change Plan dialog (dead with a single plan); add an annual-upgrade
banner for monthly subscribers (swapToYearly).
- Current-plan card shows the workspace count instead of the plan name.
System AI
- Brand analyzer / workspace autofill is always allowed and never debits credits
(system feature, not the user's usage).
Self-hosted (SELF_HOSTED=true) bypasses all billing, credit, limit, network,
and onboarding logic.
A past_due subscription made subscribed() return false (Cashier default),
so EnsureAccountReady redirected to /subscribe — which starts a brand-new
checkout and creates a second subscription. Past-due users already have a
subscription; they only need to update their payment method.
Enable Cashier::keepPastDueSubscriptionsActive() so past_due counts as
active and users keep navigating. Surface a past-due notice in the sidebar
footer linking to the Stripe billing portal (not /subscribe). Both trial
modes (subscription trial / generic trial) are unaffected.
Connect a channel by issuing a one-time code the user posts as /connect <code>
in their channel. A secret-token-guarded webhook matches the code, links the
channel as a SocialAccount (chat_id in meta), and records it on the request so
the connect endpoint can poll for completion. Adds the TelegramConnectRequest
model + migration, the connect/status endpoints, the public webhook route (CSRF
exempt), a ConnectionVerifier branch (getChat liveness), and a telegram:set-webhook
command. Tests cover the code issue, webhook link, secret rejection, expired/
unknown codes, status polling, and the command.
Register Telegram as a platform: Platform/ContentType enum cases, a
platforms.telegram config block (shared bot token via env), TelegramPublisher
(sendMessage / sendPhoto|Video|Document / sendMediaGroup over the Bot API, HTML
parse mode, 4096 limit with long text split off a 1024 caption), wired into the
publisher dispatch. Add a Telegram ContentSanitizer branch (Telegram-allowed
HTML + ampersand escaping), MediaOptimizer/profile-url/factory support, and the
TelegramPublishException. Tests cover text, single media, album, long-text split,
overflow, API errors, private-channel URLs, and sanitization.
- Add AutomationRun::durationInMilliseconds() as the single source of truth
for the Invocations list and metrics, replacing the duplicated inline diff.
- Fold the variables and root_run_id columns into their create migrations
(this branch isn't in production) and drop the standalone alters.
- Import Illuminate\Http\Response (aliased) instead of referencing it inline.
The scheduler command ran every minute and loaded all active automations,
then filtered by trigger_type in PHP because that value lived buried in the
nodes JSON array — effectively a full-table scan plus a JSON decode per row
each minute, discarding every non-schedule automation.
Derive trigger_type into a real, indexed column on save (recomputed in the
existing saving() hook so it can never drift from nodes) and filter on it in
SQL. Applies to both the schedule firer and the post-trigger dispatcher.
Split the automation detail screen into four route-based tabs behind a
shared AutomationHeader:
- Workflow: the existing editor canvas.
- Invocations: a paginated, filterable run log with expandable per-node
detail, a refresh control, and a loading state.
- Metrics: KPI cards, a runs-over-time @unovis chart with locale-aware
date labels, and a posts-by-platform breakdown over a date range.
- Settings: rename, an activate/pause switch, and a danger-zone delete.
Invocations and Metrics report only real executions via a new
productionRuns scope, so manual test runs (dry or with real data) never
leak into the log or the charts. The now-unused excludingDryRuns scope
is removed.
Generated copy now flows the most-restrictive platform context through
the humanizer too, and the editor guide documents every available
expression grouped by source node.
Replace free-text brand_tone/brand_voice_notes with a single structured
brand_voice_traits JSON column backed by the BrandVoiceTrait enum, exposed
as choice-chip pills in the brand settings UI and autofillable from a site.
Brand voice and visuals become per-automation toggles on the Generate node.
Unify the image controls into one 0-10 picker (0 = text-only, 1 = single,
2+ = carousel) and feed the generator the most restrictive selected network
so copy fits every platform. Pass that same platform context through the
humanizer pass — extracted into a shared ResolvesPlatformCopyBudget trait —
so the rewrite can no longer drift past the character cap the generator
respected, in both the automation and manual creation flows.
Persist the trigger node's schedule editor fields on save (they were
silently dropped by validated() for lacking validation rules).
Automations editor:
- {{ }} expression autocomplete in CodeMirror, scoped to the braces and
graph-aware (suggests only what upstream nodes provide + variables + now);
migrate the Generate prompt to CodeMirror so it shares the same completions
- Expandable editors: an expand button slides out a side-by-side panel
(matching the sidebar card), with a minimize control; the inline field
collapses to a hint while editing in the panel
- Hover-revealed editor toolbar (expand/copy) with styled tooltips so the
buttons no longer obscure the text while reading
- Beta badge on the Automations sidebar item
- Delete a single connection with Backspace/Delete (edge selection)
- Re-key node config so switching between same-type nodes refreshes the form
HTTP fetch node — cover every JSON response shape:
- Top-level array, object map (items_path=*), array of primitives, and NDJSON
- Key-based dedup via item_key_path (seen-set, FIFO-capped) for feeds without
dates; first poll records a baseline and emits nothing (date path too)
Fan-out test visibility:
- root_run_id links every forked branch back to the run that started a test,
so the test panel aggregates all branches instead of one
Fix a few pre-existing type issues (ScheduleData import, padded minute,
optional created_at).
- Added support for workflow variables in automations, allowing users to define reusable values.
- Implemented validation for Generate nodes to ensure intended image counts align with selected accounts.
- Updated automation models and requests to handle new variables, including encryption for sensitive data.
- Enhanced UI to display variables and their management within the automation editor.
- Improved error handling for webhook and HTTP nodes to prevent requests to invalid URLs.
- Refactored various components for better context resolution during automation runs.
Move PostObserver, AutomationRunObserver, and AutomationNodeRunObserver onto
their models with the #[ObservedBy] attribute (the Laravel-preferred way) and
drop AppServiceProvider::configureObservers() plus its now-unused imports.
- Added new automation-related routes and controllers for managing automations.
- Introduced automation nodes in the UI with distinct styles and interactions.
- Updated sidebar to include navigation for automations.
- Enhanced post creation logic to support automation metadata.
- Refactored content type and platform enums into types for better type safety.
- Added localization for automation-related terms in English, Spanish, and Portuguese.
- Improved error handling in various components to accommodate new features.
Add a trypost config toggle to switch between card-required checkout trials and no-card signup trials, and wire signup, checkout, access gating, UI copy, and tests to both modes.
Co-authored-by: Cursor <cursoragent@cursor.com>
Revert the no-card signup trial flow so access depends on a Stripe subscription trial started at checkout, preventing app access before a payment method is collected.
Co-authored-by: Cursor <cursoragent@cursor.com>
Use Account::postsCountCacheKey everywhere, avoid findOrFail when syncing
post deletes, dispatch SyncAccountUsage after DeleteWorkspace when enabled,
and add coverage for the new paths.
Co-authored-by: Cursor <cursoragent@cursor.com>
Production incident: a customer's Facebook Page post failed with 'The post
is empty. Please enter a message to share.' (error code 197) and ended up
with a contradictory DB state (status=published + error_message=set).
Three independent bugs were uncovered:
A. FacebookPublisher sends 'message'/'description' as null when the user
posts media without text. Graph API requires the key be omitted, not
null. Fixed in publishSingleImagePost, publishMultiImagePost,
publishVideoPost, publishReel.
B. markAsPublished/markAsFailed leak stale fields across transitions
(a published row could retain error_message from a prior failure,
vice-versa). Both transitions now explicitly clear the opposite
side's fields.
C. status='failed' was editable in the UI and the backend, so users
were re-clicking Publish, generating duplicate failure emails and
the contradictory state from bug B. The frontend isReadOnly check
and the UpdatePost backend guard now treat Published/PartiallyPublished/
Failed/Publishing as terminal. To retry, the user duplicates the post.
11 new tests guarantee these can't regress silently: FB payload shape
per content type, PostPlatform field-clearing on transitions, and the
terminal-status block at the controller level.
Onboarding/lifecycle workflows in PostHog (and downstream tools like SendKit)
need to segment users by how many social accounts they've connected and how
many posts they've created. The existing SyncUser job only re-emitted these
counts on signup and billing changes, so the values went stale the moment a
user did anything meaningful.
This wires up two new paths that refresh the account group automatically:
- SocialAccountObserver (#[ObservedBy] on the model) fires SyncAccountUsage
on created/deleted, covering all 14 OAuth callback paths in one hook.
- SyncUsageOnPostCreated / SyncUsageOnPostDeleted listeners (auto-discovered)
fire SyncAccountUsage on the corresponding events dispatched by CreatePost
and DeletePost.
SyncAccountUsage is the new dedicated job for group properties only
(groupIdentify account + workspace). SyncUser was slimmed to just identify
the user and delegate the group sync, removing the duplicated property
mapping between the two jobs.
All entry points (observer + both listeners) short-circuit when PostHog is
disabled, so self-hosted instances without PostHog configured see zero
queued jobs and zero overhead.
posts_count cache is invalidated before each sync so the job reads fresh
counts from the database instead of stale cached values.
Centralizes 'what date should the UI show as trial end?' on the model.
Returns null when not on trial, the subscription's trial date when on
trial-with-card, or the generic trial date for no-card users.
New signups land on a 7-day generic trial (Cashier trial_ends_at) without
a Stripe customer or subscription. Account is on Starter plan limits during
the trial. After 7 days, EnsureAccountReady redirects to /subscribe per the
existing flow.
- CreateUser sets account.trial_ends_at and plan_id = Starter
- EnsureAccountReady allows access when subscribed() OR onGenericTrial()
- Account::isOnTrial() includes generic trial check
Existing users unaffected: paying users have a subscription;
never-paid users continue redirecting to /subscribe.
Two follow-up fixes from the code review:
1. **Unified lock key for markAsDisconnected / markAsTokenExpired.**
Both methods now use `social_account_status:{id}` instead of
different keys. Prevents the race where `markAsDisconnected` and
`markAsTokenExpired` could run concurrently on the same account
(publish-time vs verify-batch-time), causing overlapping updates and
duplicate notifications.
2. **i18n for notification title/body in markAsTokenExpired and
markAsDisconnected.** Strings were previously hardcoded in English.
Added `notifications.account_disconnected.{title,body}` and
`notifications.account_token_expired.{title,body}` in en, pt-BR, es.
Follows the project convention (e.g. `Mail/PostPublished`) of
concatenating the `@` prefix in PHP before passing the username to
the translation placeholder, instead of putting `@:account` in the
lang file.
Three related fixes for the failure mode where a scheduled post errors out
as 'An unknown X error occurred.' when a social account's refresh_token
was already invalidated by the provider:
1. **PublishToSocialPlatform**: fail-fast when account status is
`TokenExpired`. Previously the job tried to publish, the publisher
internally tried to refresh, the provider rejected the rotated
refresh_token, and the failure surfaced as a generic 'unknown' error
instead of a clear 'reconnect your account' signal.
2. **XPublisher::refreshToken**: when the OAuth endpoint rejects the
refresh_token (typically because it was rotated/revoked at X), log the
raw response and throw `TokenExpiredException` instead of falling
through to `XPublishException::fromApiResponse` which expects the
tweet-API response shape (`type`/`title`/`detail`) and treats
OAuth-style responses (`error`/`error_description`) as 'Unknown'.
3. **SocialAccount::markAsTokenExpired**: dispatch an in-app + email
notification (`Type::AccountDisconnected`) when an account
transitions from `Connected` → `TokenExpired`, mirroring the
existing pattern in `markAsDisconnected`. Wrapped in a lock to
prevent duplicate notifications on concurrent transitions. Accepts an
optional `notify: false` so the batch verifier
(`VerifyWorkspaceConnections`) can suppress per-account
notifications and rely on its summary email.
Core changes:
- Replace Unsplash slide pipeline with gpt-image-2 via Laravel AI SDK.
New AiImageClient builds prompts from a Blade template seeded by the
workspace's ImageStyle enum, content language, brand color (mapped to a
human-readable name via HexColorName helper) and brand description.
- Drop Template B from TemplateImageGenerator: every slide now renders as
Template A (full-bleed photo + bottom gradient + white/grey overlay).
Removes renderTemplateB, roundCorners, blendHex, ensureContrast and the
closing-slide pipeline.
- StreamPostCreation creates the Post directly and dispatches
PostCreationReady with post_id; the wizard kills its preview step and
redirects straight to the post editor on completion. Finalize endpoint
removed.
- New Workspace.image_style enum field with an 8-option visual picker
shared by /workspaces/create and /settings/workspace/brand via a single
BrandForm component (autofill is a prop). 8 sample webp thumbs ship
under public/images/branding/image-styles/.
- Media items gain optional source ('ai'|'unsplash'|'giphy') and
source_meta (recipe needed to regenerate AI images later); the gallery
picker tags Unsplash/Giphy attachments.
- Brand-color autofill: new CssColorFrequencyExtractor parses every
hex/rgb/hsl value in the homepage CSS, clusters perceptually similar
shades in CIE LAB (Delta E 76 < 12), filters neutrals and returns the
most frequent cluster. Solves Tailwind/utility-CSS sites where no
semantic --primary variable is exposed.
- Credits: gpt-image-2 metered at 15 credits/image (low quality default).
- Layout: AuthSplitLayout right column is sticky/h-svh so the form
textarea growth no longer stretches the marketing slider.
- i18n cleanup: localized labels follow the no-em-dash convention.
Class names and method signatures already explain what these classes
do. Project guideline (CLAUDE.md): comments only when the WHY is
non-obvious — implementation details belong in commits, not noise on
top of every class. Kept the one comment that earns it: the (int)
cast in HasUsage::cachedPostCount, which documents the load-bearing
workaround for Laravel's Redis cache numeric optimisation.
Production crashed on every Inertia request after the PostHog branch
landed:
TypeError: App\Models\Account::cachedPostCount(): Return value must
be of type int, string returned at app/Models/Traits/HasUsage.php:80
Root cause: Laravel's RedisStore optimises is_numeric values by storing
them raw (not serialised) so they remain INCR/DECR-able atomically.
The side effect is that an int written via Cache::put comes back as a
string on read. The strict ': int' return type on cachedPostCount then
threw a TypeError.
Local dev and CI used the file/array/database drivers respectively,
which serialise everything blindly and preserve the int type, so the
bug never surfaced before deploy.
Fixes:
- Cast the Cache::remember result to (int) — defensive, survives any
driver-specific behaviour. Documented inline so the cast is not
later removed as redundant.
- Change config/cache.php default from 'database' to 'redis' so local
dev matches prod by default and similar driver-specific bugs surface
before merge instead of after deploy.
- Regression test that seeds the cache with a literal string (mimics
the production Redis read) and asserts cachedPostCount still returns
an int.
Replaces the implicit Account::booted() observer with an explicit
Account::forgetPlanFeatureCache() method called from each plan_id
mutation site (StripeEventListener x3, BillingController x2). Self-hosted
installs naturally never reach any of these callsites — Stripe webhooks
do not fire and the billing controllers redirect to /calendar before any
plan mutation happens — so the Pennant flush is now guaranteed to be a
cloud-only operation.
Adds integration coverage proving the full chain webhook -> plan_id
update -> Pennant flush -> next Feature::value resolves against the new
plan limit.