* 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.
* Make Stripe Checkout configurable via billing env knobs
Replace the hard-required $1 first-month coupon with env-driven trial days,
optional coupon, and allow_promotion_codes so SaaS can switch checkout modes
without a code change.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix no-effect ReflectionClass import in checkout test
CI treats bare global use statements as ErrorException and aborts
loading the suite before any assertions run.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Document checkout env knobs in AGENTS.md instead of .ai/
Remove the Boost record-rule .ai/rules folder and keep durable billing
checkout guidance in AGENTS.md / CLAUDE.md project-specific rules.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden checkout env knobs from review findings
Default allow_promotion_codes to false, grant Stripe trial only to
first-time subscribers, clarify the coupon/promo XOR error, and cover
negative XOR cases plus StartSubscriptionCheckout wiring.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add optional Pinterest pin title, description, and link.
Expose title/description/link across web, API, and MCP; seed description from caption into meta on save, and publish description only from meta.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Expand Pinterest title/description/link test coverage.
Cover web draft persistence and validation bounds, API/MCP update merge and seed, and publisher payload fields on video and carousel pins.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify Pinterest: description is post content again.
Keep optional title and link in meta/settings only. Remove the separate description textarea, seed logic, and meta.description path so Pinterest follows the shared caption pattern.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refactor Pinterest meta handling and validation.
- Update CreatePost and UpdatePost actions to filter out null values from meta fields.
- Introduce a new method in PinterestPublisher to resolve board IDs, ensuring required fields are validated.
- Enhance PinterestSettings component to manage title and link inputs, including validation for HTTP URLs.
- Update PostPlatformMetaRules to enforce URL validation for Pinterest links.
- Add tests for clearing Pinterest title and link, and for rejecting invalid links during scheduling.
This refactor improves the handling of Pinterest metadata and enhances user experience by ensuring proper validation and error handling.
* Add validation messages and attributes for Pinterest meta fields
- Introduced custom validation messages and friendly attribute names for Pinterest link and title fields in PostPlatformMetaRules.
- Updated StorePostRequest, UpdatePostRequest, and related tools to utilize these new messages and attributes.
- Enhanced tests to assert correct error messages for invalid Pinterest links and title length constraints.
This update improves user feedback during post creation and editing, ensuring clarity in validation errors.
* Remove click.prevent directive from Pinterest link in PinterestPreview component.
This change simplifies the link behavior, allowing default click actions to occur, which may enhance user interaction with the Pinterest link.
* Update validation error messages for Pinterest meta fields in tests
- Refined the assertions in PostApiPlatformMetaTest to include localized validation messages for Pinterest title and link fields.
- Ensured that error messages reflect the updated validation rules, enhancing clarity for users during post creation and editing.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: keep post drafts unscheduled by default
* Align schedule validation and keep drafts unscheduled.
Require scheduled_at only when status is scheduled and the post has no
usable future schedule. Share that rule across web, API, and MCP, keep
create without a date as null, and preserve the legacy date → 09:00 UTC
fallback.
* Polish schedule validation typing and tests.
Type requiresExplicitSchedule status as ?string, reuse a local status
variable in request/tool validation, tighten the web reject assertion,
and collapse overlapping MCP unscheduled-create cases.
* Centralize status helper in post update validation.
Reuse the typed status() helper across FormRequests and the already-parsed
$status in UpdatePostTool so schedule checks stay consistent and less noisy.
* Share scheduled_at update rules across web, API, and MCP.
Centralize schedule validation in PostStatusRules, normalize status parsing
in one place, and align past-schedule coverage across entry points.
* Cover the full unscheduled-draft checklist in Pest.
Add feature coverage for null/past schedule rejection, explicit scheduling,
draft saves, publish-now without a schedule, calendar exclusion, and
09:00 UTC date defaults across web, API, and MCP.
* Remove normalizeStatus helper.
Keep the inline is_string check at the few call sites that read raw
request status before validation — no shared wrapper needed.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop is_string status guards from schedule validation.
Accept mixed status in PostStatusRules and rely on strict comparisons
with Rule::requiredIf / Rule::when — malformed input simply does not match.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
Bluesky does not hydrate link cards server-side, so build the app.bsky.embed.external embed at publish time: detect the first URL, scrape its OpenGraph metadata, and re-upload the og:image as the card thumb. Works for web, API and MCP. Adds a posts/link-preview endpoint so the editor renders the card live. The thumb download is SSRF-guarded and does not follow redirects.
- Rename AiPromptRules::promptRule() to wizardPromptRule() so the asymmetry is
explicit: only the create wizard carries a minimum; the editor's generation
reuses just the shared maximum.
- Add aria-live and a data-testid to the prompt counter so the over-limit state
is announced to assistive tech and reachable from browser tests.
The counter added earlier drifted from the backend in two ways: it counted
UTF-16 code units over the raw (untrimmed) value, while the backend measures
Unicode characters (mb_strlen) over the trimmed value that is actually sent —
so emoji or trailing whitespace could falsely turn the counter red and block
the button. The 2000 limit was also copied into three places, and the wizard's
frontend `>= 3` minimum had no backend counterpart.
- Add App\Support\AiPromptRules as the single source of truth for the prompt
bounds; both StartPostCreationRequest and GeneratePostContentRequest use it.
- Add min:3 to the create wizard endpoint so front and back agree (the editor's
generate-content flow keeps `required` — it has no counter to mirror).
- Count code points over the trimmed value in AiPostWizard so the counter and
the submit gate match what the backend validates, matching AltTextDialog.
- Cover min/max/boundary in PostAiCreateTest.
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.
Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.
Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.
Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.
Adds unit, feature, MCP, and browser tests covering every path above.
A raw subscriptions()->exists() check treated a leftover incomplete /
incomplete_expired row — which Cashier persists when a first payment
fails or a 3DS challenge is abandoned — as a prior subscription, so a
genuinely new customer retrying after a failed first attempt lost the
$1 coupon and was charged full price. Exclude those never-started
statuses so only a subscription that actually started (active, canceled,
etc.) marks the account as a returning customer.
The fixed amount_off coupon only nets $1 for a quantity of one, and the
offer is meant for genuinely new signups. A lapsed account that kept
several workspaces and re-subscribes through onboarding would otherwise
be charged N*price - amount_off (not $1), and a returning customer could
whittle down to one workspace to claim the discount again.
Apply the coupon only when the paid first month is enabled, the account
bills a single workspace, and it has never subscribed before; every other
checkout falls back to allowing promotion codes at the full price.
Two hardening fixes for the paid first month:
- FirstMonthCheckoutDiscount throws when the paid first month is enabled
but STRIPE_FIRST_MONTH_COUPON_ID is unset, instead of silently charging
every new customer the full price with no discount.
- Guard workspace store() with the same active-subscription check create()
already applies, so a direct POST can't bootstrap a second billable
workspace and inflate checkout quantity past the fixed first-month coupon.
A $0 trial only runs a weak card authorization, so invalid cards slip
through and only fail once the real charge fires days later. Applying a
$11-off, duration=once Stripe coupon at checkout charges $1 for real on
the first invoice instead, which validates the card immediately, then
reverts to the full monthly price on the next invoice with no manual
swap needed.
Final-review follow-ups:
- MediaAttacher::resolveInlineMedia now deletes the media it hosted in this call
when any item fails, so a mixed [good, bad] batch no longer orphans the good
item's Media row + file while the request is correctly rejected with 422. Makes
the create/update media resolution truly all-or-nothing.
- PostMediaRules: keep source/source_meta on both contracts (the API previously
passed them through with no item rules — don't silently drop them) so the media
item shape is uniform; only id/path/url differ by contract.
- Make MediaAttacher::fetchToWorkspace private (no external callers).
- Test the partial-batch rollback (no Media, no post persisted).
The media.* rules were duplicated across the web update request and both API
requests (and diverged: web requires hosted id+path and tracks source; the API
accepts a bare external url it downloads). Pull them into one
App\Support\PostMediaRules::rules(hosted:) — same pattern as PostPlatformMetaRules
— parameterized by contract, so there's a single place to add a media key and the
validated()-strips-unlisted-keys footgun can't drift between entry points.
Behavior is unchanged (each ruleset is reproduced exactly). Web store keeps its
loose 'media' => array (no item rules) and is left out on purpose — adding strict
rules there would change the web create contract.
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.
Bug fix
- Surface the localized "network already connected" message on the
Facebook/Instagram/InstagramFacebook/LinkedInPage/Threads/YouTube OAuth
callbacks: catch NetworkAlreadyConnectedException before the generic catch
so the conflict no longer falls through to a generic error + Log::error.
Scope / dead code
- Remove the orphaned BillingController::checkout() + app.billing.checkout route
(onboarding starts checkout directly); delete the now-dead DiscordWidget and
useFeatureAccess composable; prune orphaned i18n keys left by removing the
plan picker / upgrade dialog / count limits (billing.subscribe.*,
accounts.limit_reached, workspaces.limit_reached, common.discord.*).
- Drop the unused `plan` prop from the usage page and the unused `label` from
the onboarding persona payload (labels come from i18n); remove
Persona::options()/label().
Conventions
- declare(strict_types=1) on the two new migrations.
- Extract autofill validation into AutofillBrandRequest (FormRequest).
- Drop the unused $plan param from AccountPolicy::swapPlan.
- CreateUser: drop the stale config('cashier.trial_days', 7) fallback (now 8).
- Rename LimitEnforcementTest to InvitePermissionTest; use Pest mock() helper in
WorkspaceQuantitySyncTest; move shared test helpers into Pest.php.
- Memoize BillingCycle window() + subscription lookup.
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.
The shared rules() returned only meta sub-keys, so every caller still repeated
its own 'platforms.*.meta' => array parent (with slightly different sometimes/
nullable combos). Fold the parent into rules() with a single safe contract so
callers just spread one source for the whole meta block.
Centralize per-platform PostPlatform.meta validation in PostPlatformMetaRules
(shared by web, REST API and MCP). The API and MCP previously only accepted
aspect_ratio, silently stripping channel_id/board_id/privacy_level and the rest
via validated(), so Discord/Pinterest/TikTok couldn't be configured or published
through those entry points. Now all per-platform meta is accepted and persisted,
required-on-publish is enforced on API update (TikTok privacy, Pinterest board,
Discord channel), and the MCP publish tool guards a post's stored meta before
dispatching. Adds API + MCP tests and a PostPlatform discord() factory state.
- Removed the PostStatusGuard class and replaced its usage with the new PostStatusRules utility across multiple controllers and actions, enhancing code organization and maintainability.
- Updated error message handling to utilize the centralized method in PostStatusRules, ensuring consistency in user feedback.
- Deleted associated tests for PostStatusGuard, reflecting the removal of the class.
- Replaced direct status checks in multiple controllers and actions with the PostStatusGuard utility, improving code readability and maintainability.
- Updated error messages to utilize a centralized method for consistency across the application.
- Removed the BrandImagePalette class, consolidating color resolution logic into the AiImageClient for better organization and type safety.
Let users adjust AI-generated slides in place via async job and Echo, while applying workspace brand, background, and text colors to image prompts. Autofill swaps site text/background colors for the image palette, and regeneration is blocked on finalized posts with safer job cleanup.
Co-authored-by: Cursor <cursoragent@cursor.com>
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.
Mentions in post comments
- @mention autocomplete (workspace members, current user excluded) with
marker syntax @[uuid] persisted, display names rendered via CommentBody
chips; live edit replaces markers with names and converts back on save.
- NotifyMentions action with workspace-scoped membership check, dedupes
same user, only newly-added mentions on update.
- Email + in-app via SendNotification job, respecting per-user
notification_preferences.mentioned_in_comment.
- Heartbeat-based presence (Cache, 60s TTL, 30s ping) so online recipients
get only the in-app notification — no email noise.
- Real-time bell on workspace.{id}.user.{id} private channel
(NotificationCreated event), scoped channel name avoids client-side
filtering and lays out a convention for future workspace channels.
- Mailable localized via lang/{en,es,pt-BR}/mail.php; Maizzle source
template for the email is committed and built into resources/views/mail.
AI generation refactor (Action layer + MCP)
- Extracted Actions/Ai/Generate{Image,Video} with QuotaExhaustedException
so agent tools and MCP tools share a single domain entry point.
- Mcp/Tools/Ai/Generate{Image,Video}Tool registered in TryPostServer; both
return MediaResource payloads.
- Orientation::imageApiSize maps non-OpenAI ratios to 1:1/2:3/3:2.
- config/ai.php is now the single source of truth driven by env, removing
the trypost.ai shim. Default text/image providers flipped to OpenAI.
Settings/UX
- /settings/workspace split into shadcn Tabs (Workspace / Brand / Users)
with three components.
- /assets and the in-editor MediaPicker open the ImagePreviewDialog
lightbox on image click while preserving action button behaviour.
- Comments tab landed via ?tab=comments&comment=<id> from notification
click (scroll-to + temporary highlight).
- Mention autocomplete popover flips above when near the viewport bottom.
- Real social platform PNGs replace Tabler brand glyphs in schedule
pills and post list, with hover tooltip carrying display_name + handle.
Bug fixes
- AcceptInvite: controller now passes workspace + role payload that the
Vue page expects; login/register CTAs preselect the invite email.
- WorkspaceInvite mailable: stopped referencing nonexistent
$invite->workspace and $invite->role; column added to the migration,
Invite model casts role to WorkspaceRole, CreateInvite persists it.
- PostCommentCreated: added broadcastAs so .PostCommentCreated actually
matches the Echo listener; payload now includes mentioned_users so
receivers render the chip correctly without a refetch.
- Preview components for X/Pinterest/Threads/Bluesky/LinkedIn/Mastodon/
TikTok/YouTube switched from item.type === 'image' to
!isVideoMedia(item) so media without a persisted type still renders.
- UpdatePostRequest now accepts media.*.{type,mime_type,size,...} so the
posts.media JSON keeps the metadata that the previews need.
- Removed throttle:6,1 from social connect routes (was 429ing legitimate
OAuth retries).
- Used MediaType enum cases instead of literal 'image'/'video' strings
when creating media rows.
Tests
- MentionParser unit tests, NotifyMentions feature tests including
online/offline channel selection and preference gating, MCP AI tool
happy paths, MentionedInComment mailable rendering, AcceptInvite +
search-members + index mentioned_users path. 1229 passing.