* Remove the post-templates feature
Removes the browsable post-templates catalog end to end: routes
(app.post-templates.index/apply), controller, form requests, resource,
Registry/PostTemplateData/TemplateNotFoundException services, the
templates:report console command, the templates/ catalog (36 files
across en/es/pt-BR), the templates Index.vue page, the template card
on the post Create.vue choice screen, and its i18n keys across all 16
locales.
Also removes TemplateContextResolver, which fed catalog examples into
PostContentGenerator's AI prompt. The "examples" block (with its
heading) is removed from generator.blade.php rather than left empty;
AI generation no longer references the templates catalog.
The AI content-template system under app/Ai/Templates (image/tweet
card templates for the AI post wizard) and TemplateImageGenerator are
unrelated and untouched.
* Update FormRequest naming examples after the post-templates removal
CLAUDE.md and the Cursor rules cited ApplyPostTemplateRequest and
IndexPostTemplateRequest as naming examples. Both classes were deleted
with the feature, so the guidance pointed at files that no longer exist.
* Share the AiTemplate type between the create screen and the AI wizard
Create.vue and AiPostWizard.vue each declared their own AiTemplate
interface, and they had drifted: the wizard's carried
applies_brand_visuals, the create screen's did not. TypeScript treated
them as unrelated types with the same name, which surfaced as a TS2719
error where the templates prop is passed between them.
The backend sends seven fields (PostController::create), so the shared
declaration follows the payload rather than the union of the two copies.
* fix: subscribe to AI generation channel before dispatching the job
AiGenerateDialog posted the generate request and only subscribed to the
broadcast channel once the response came back. Reverb's plain Channel
(vendor/laravel/reverb/.../Channels/Channel.php) delivers broadcasts by
iterating the in-memory connection list at send time only — no history,
no replay. If the queued job started streaming text_delta/stream_end
events before the private-channel subscribe handshake finished, those
events were gone for good and the dialog hung on 'streaming' forever.
Generate the generation_id client-side, await channel subscription
confirmation (Echo's channel.subscribed(), backed by
pusher:subscription_succeeded) before sending the generate request, and
have the backend use the client-supplied id instead of minting its own.
Related to #218.
* fix: handle subscribe failure, dialog-close race, and i18n hardcoded strings
Code review on the previous commit surfaced real gaps:
- useAiStream only handled the subscribe-success path (.subscribed()).
A definitive pusher:subscription_error (expired session, CSRF
mismatch) fell through to the 5s timeout and was treated as success,
reproducing the exact #218 hang via a different trigger. subscribe()
now returns a boolean: true on confirm-or-ambiguous-timeout (proceed
optimistically), false only on an explicit error (don't dispatch work
nothing will ever hear).
- AiGenerateDialog now bails without dispatching if the dialog is
closed while awaiting subscription confirmation (up to 5s), and
unsubscribes on a post-subscribe dispatch failure so stale listeners
can't flip status to 'completed' after an error was already shown.
- Extracted aiGenerationChannel() so the frontend has one place to
update if the channel format changes, and cross-referenced the two
remaining backend copies (PostAiGenerateController, StreamPostContent).
- Replaced the two hardcoded English fallback error strings with i18n
keys (posts.ai.generate.errors.*) across all 16 locales — this app
only ships translated copy.
- Simplified subscribe(): dropped a redundant "already settled" guard
and the clearTimeout bookkeeping — Promise.resolve() already no-ops
after the first call, so there was nothing to guard.
* fix: subscribe before dispatch in AI post creation and image regeneration flows
Extracts subscribePrivateChannel as a shared helper and applies the same
subscribe-before-dispatch fix from the post edit flow (issue #218) to the
other two flows that stream over private channels.
Post creation wizard moves the StreamPostCreation dispatch out of
AiPostWizard.vue and into Loading.vue, so the channel is subscribed before
the job that broadcasts on it is dispatched — previously a full page
navigation happened between dispatch and subscribe with no timeout
fallback, so a lost event hung the page forever.
* refactor: trim explanatory comments added in the subscribe-before-dispatch fix
Rationale lives in the commit history, not scattered inline comments.
* refactor: trim remaining explanatory comments from the subscribe-before-dispatch fix
Same cleanup as the previous commit, applied to the files from the
original post-edit-flow fix.
* fix: dedup post-creation dispatch, surface field-level AI errors, harden edge cases
Uses ShouldBeUnique on StreamPostCreation (scoped per-user) instead of an
ad-hoc cache guard, matching the pattern already used by
PublishToSocialPlatform and VerifyUpcomingPostConnections.
Field-level validation errors (prompt/instruction) now render inline via
InputError, matching how every other form in the app surfaces them, instead
of a generic status banner. Also fixes a 422 response missing the `errors`
key that Inertia's client silently swallows, adds error handling around the
wizard's navigation to the loading page, and extracts the duplicated
error-message parsing into a shared helper.
* 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.
* Localize calendar date pickers to the active UI locale.
Pass the Inertia locale into Reka calendars, translate a11y labels, and use dayjs LL/LLL for DatePicker display text.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Localize remaining dayjs date displays across the UI.
Route list/calendar/preview timestamps through localized LL/LLL helpers so formats follow the active UI locale instead of English or Portuguese-locked patterns.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use scheduled-or-now timestamps in platform previews.
Drive preview labels from the post schedule when set, localize Discord without dayjs calendar(), and expose a single formatPreviewPostedAt helper.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix preview schedule wiring and Discord/Facebook timestamp labels.
Restore hasPickedTime from any saved scheduled_at, keep time on non-today Discord labels, and use a localized just-now fallback for unschedled Facebook previews.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix API key expiry day shift and tighten calendar range titles.
Format date-only expiry in UTC calendar days, and use compact dayjs titles for calendar day/week headers.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Replace frontend date source greps with Inertia API key assertions.
Drop the Vue/TS file scanner and assert expiry calendar days through ApiKeyController props instead.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Localize calendar week titles with day-first dayjs formats.
Avoid English month-day order in week headers and chart axis labels so locales like pt-BR keep natural day-first dates.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Split preview timestamp formatting into named platform helpers.
Replace the style-switch formatPreviewPostedAt with clear per-platform helpers so call sites read as formatDiscordPreview / formatXPreview / etc.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Share {boards, truncated} via ListPinterestBoards into Inertia and warn in the board picker when pagination stopped early, matching API/MCP.
Co-authored-by: Cursor <cursoragent@cursor.com>
- automations: minimal back-only header on mobile for the workflow builder
and detail tabs (extracted AutomationMobileBackHeader); open live
automations on the metrics tab (drafts still open on workflow); full-width
status filter + refresh on the invocations toolbar; use IconMenu2 for the
mobile sidebar trigger
- dialogs: stack DialogFooter primary-on-top / cancel-at-bottom on mobile
- workspaces: bring the workspace picker cards into the neo-brutalist design
- posts: left-align the label filter content; wrap the post-view date/status
header so a long status badge no longer squeezes the date; add hamburger
clearance to the editor's mobile tab bar
It's only the autosave indicator (Draft/Saving/Saved) and it crowded the tabs.
Drop it from the mobile switcher (the desktop header still shows it) so the tabs
get the full bar.
On mobile the header was a whole bar showing only the Draft/Saving/Saved status.
Move that status into the switcher bar (right side) and hide the header on mobile
(the scheduled banner still shows), reclaiming a row. The switcher clears the
floating hamburger when it's the top bar.
Stack it into a clean back-button row and a date + status row (date left, badge
right) instead of cramming everything into one line where the date wrapped and
the badge floated.
Split the header into a dedicated mobile layout (< lg) and the desktop grid.
Mobile is two clean rows: prev / date picker (fills) / next, then Today +
full-width New post. Fixes the stretched, styleless Today button. Each header
owns its own controls, so the date picker and view switcher no longer need
lg:hidden / hidden lg:block toggles.
- Show/hide the date picker and view switcher with Tailwind (lg:hidden /
hidden lg:block) instead of the isMobile ref; isMobile now only drives
effectiveView (which changes navigation behavior, not just visibility)
- Full-width buttons on mobile: prev / Today (fills) / next, and the date
picker fills the row next to New post
Non-wrapping justify-between headers now stack below sm; fixed-width searches go full-width. Calendar header wraps and seeds isMobile at setup to avoid the month-grid flash.
- Top switcher (Compose / Channels / Preview / Comments) reveals the panel that was hidden below lg
- Sticky bottom action bar puts schedule/publish/delete within thumb reach
- Composer media actions and comment toolbar are visible on touch (no hover needed); media grid is 3-up
- Clamp media picker and emoji picker widths to the viewport
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.
The workspace switcher menu, the label filter, and the templates category
popover still used the v3 bracket syntax w-[--reka-*-trigger-width], which
Tailwind v4 compiles to an invalid `width: --reka-...` so the floating content
no longer matched its trigger. Switch them to the v4 CSS-variable syntax
w-(--reka-*-trigger-width), matching the FontPicker/LanguagePicker fix.
Five hand-rolled formatDuration variants (two of them byte-identical
across the automations pages) collapse into formatClock, formatDurationWords
and formatDurationMs on the date helper, all backed by dayjs.duration.
Migrates the video badge, AI loading timer, media validation params, and
the automation metrics/invocations tables onto them. Output is unchanged
except the loading timer's minutes are no longer zero-padded (5:30, not 05:30).
ImagePreviewDialog now renders documents via an <iframe> (the browser's
native PDF viewer) alongside images and videos. The post view, gallery
browser, and editor composer no longer divert PDFs to a new tab or strip
them from the collection — a PDF opens in the dialog and navigates
(prev/next) together with the other media.
Mirror the backend App\Enums\Media\Type on the frontend so "is this an
image/video/PDF?" has a single definition instead of being hand-written
(type === 'x' || mime_type startsWith ...) across a dozen files.
- add lib/mediaType.ts: MediaType union, classify(), fromMimeType(),
fromExtension(), isImage/isVideo/isDocument/isGif, acceptAttribute()
- route every MediaItem detection and every File-upload validator through it
- fix MediaDropzone rendering a typeless image item (AI/Unsplash/Giphy)
as a broken <video> via the img-vs-video v-else
- collapse getMediaIncompatibilityReason onto the shared
getMediaValidationWarning core (canSchedule / variant selection unchanged)
- tighten MediaItem/AssetMedia/SavedMedia/PickedMedia .type to MediaType
- replace GalleryBrowser's hardcoded accept list with acceptAttribute()
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.
- store(): members without connected accounts no longer get redirected into
the now-admin-only /accounts (403); non-managers go to the calendar with
the same flash, admins still go to /accounts
- cover the SyncPostPlatforms can('update') gate (viewer creates no platform
rows; member does) and the store redirect split, in WorkspaceRolePermissions
- cover PostPolicy::duplicate viewer-denied
- docs sidebar link uses the canonical https://docs.trypost.it
- drop orphaned sidebar.support.{discord,last_updates} keys in all locales
- remove the explanatory isLocked comment in Edit.vue
Drop the 60% opacity / pointer-events lock on the editor's left column so
the client sees the draft clearly and can still click an image to open the
lightbox. Instead the composer renders read-only: the add-image tile,
remove/drag controls and the formatting toolbar are hidden and the caption
textarea is read-only, while the media click-to-preview stays active.
Viewers are typically the client: they need to open a draft in the editor
to use the comments tab, but must not change anything.
- post editor (edit) now authorizes view, so viewers can open it; the
composer + schedule tab render read-only and the comments tab stays
interactive (defaults to the comments tab for viewers)
- all mutations stay member+ (update/delete) — the autosave/save/publish/
schedule/delete affordances are hidden and the PUT is still 403 for
viewers; SyncPostPlatforms only runs for users who can update
- drafts route to the editor for everyone again (reverts the read-only
Show detour); Show stays the published-post view
- /accounts now authorizes manageAccounts (admin+), so viewers and members
get 403; the Connections sidebar item is admin+ only and the connect/
disconnect grid is reverted to main (no per-button gating needed)
Tests: draft→editor redirect for every member, viewer can open the editor,
viewer cannot save, and only admins+ can open /accounts.
A viewer clicking a draft/scheduled post landed on the editor route
(authorizes update) and got a 403. The post list/calendar routed every
editable post to the edit page, and PostController@show redirected
draft/scheduled posts to the editor for everyone.
- show only redirects to the editor when the user can update the post;
viewers get the read-only Show page
- posts index + calendar route to show (not edit) when the user cannot
create posts
- cover viewer-sees-show, member-redirected-to-editor, and
viewer-403-on-direct-edit in WorkspaceRolePermissionsTest
Viewers could mutate posts, automations and trigger AI write endpoints,
and every role saw create/manage affordances that 403'd on click.
Backend (security):
- PostPolicy update/delete now require member+ (was tenancy-only), which
also gates the AI write endpoints that authorize('update')
- AutomationPolicy create/update/delete require member+; activate/pause
delegate to update
- AutomationController authorizes index/store/show; AnalyticsController
authorizes view
- Comments stay open to members incl. viewer (by design)
Frontend (UI gating via new useWorkspaceRole composable):
- Sidebar: create post / create workspace / automations / library nav
- Accounts grid: connect / disconnect / reconnect (admin+)
- Members: invite / change role / remove / cancel invite (admin+)
- Account billing tab (owner); posts index + calendar create affordances
Tests: PostPolicyTest, AutomationPolicyTest (all four roles) and an
end-to-end WorkspaceRolePermissionsTest; aligned the automation test
suites' account/workspace setup with role pivots.
The calendar read each post's caption from post_platforms[0].content,
but post_platforms has no content column — the caption lives on
posts.content. As a result every cell fell back to the 'no content'
placeholder. Read post.content instead (matching the list view) and
drop the phantom content field from the PostPlatform interface.
Also surface the caption in the month view (it previously showed only
the time and platform icons), add horizontal padding to the month post
list so the card border is not clipped inside the overflow-y scroll
container, and standardise the hover effect across day/week/month
views to a shadow-only highlight (no lift).
Closes#70
- Refactored the media item interface by moving it to a dedicated type file, enhancing code organization and reusability across components.
- Updated various components to import the new MediaItem type, ensuring consistency and reducing redundancy in type definitions.
- Improved the RegeneratePostMediaImage job by optimizing the media regeneration process and enhancing error handling for better reliability.
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>
Parse datetime-local values in the user timezone before converting to UTC,
matching formatUtcForDateTimeLocalInput. Fix ESLint import order in usePostCompliance.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Introduced a new translation key for future date selection prompts in English, Spanish, and Portuguese.
- Implemented logic to disable the confirm button and display a warning message when a past date is selected in the PickTimePopover component.
- Updated date formatting utility to handle UTC dates for HTML datetime-local inputs.
Pull the variant-snap branch out of togglePlatform into a named
helper with guard clauses. togglePlatform now reads top-down:
lock check → deselect short-circuit → snap → select.
Pulls ~200 lines of media/platform/length compliance and tooltip
derivation into composables/usePostCompliance.ts. Edit.vue is now
focused on state, save logic and event handlers; PLATFORM_VARIANTS
and the per-content-type media-rule check are exposed from the
composable for togglePlatform.
- Add separate canDelete predicate in Index.vue that includes Failed —
the previous EDITABLE_STATUSES gating hid the delete button for
failed posts even though the backend allows deleting them.
- Edit.vue Echo handler navigates to /show when an in-page real-time
status update transitions the post into a read-only state, instead
of leaving the user on a stuck readonly editor.
- FacebookPublisher: stop using empty() for content checks (treats
literal "0" as empty) — compare explicitly against null and "".
- Update terminal-state error messages in API + MCP update tool to
reflect the broadened guard (no longer Published-only). Adjust the
matching test assertion.
- Drop 'failed' from EDITABLE_STATUSES in Index.vue and Calendar.vue
so failed posts link directly to /show instead of /edit (which now
redirects).
- Replace remaining 'scheduled'/'publishing' literals in
PostEditorHeader.vue with the PostStatus enum.
- Include Failed in PostEditorHeader's READONLY_STATUSES to match
Edit.vue.
- Split PLATFORM_VARIANTS by Platform key (Platform.LinkedInPage and
Platform.InstagramFacebook were missing; page content types were
grouped under the wrong personal-LinkedIn key).
- PostController@edit was redirecting Failed→show while show was
redirecting Failed→edit, producing ERR_TOO_MANY_REDIRECTS. Failed
posts now render in show.
- New universal `hasContentOrMedia` rule in Edit.vue blocks publishing
when both text and media are empty (closes the hole where empty posts
could reach the publish button).
- Unified `PLATFORM_VARIANTS` to include Facebook, Instagram and
LinkedIn variants. togglePlatform snaps to a compatible variant when
reselecting a platform whose current content_type is incompatible
with the attached media (fixes the case where Reel+image left the
tile permanently blocked).
- platformIssues suppresses the issue on deselected tiles when a
compatible variant exists, so the tile remains clickable and the
snap can recover state.
- Use ContentType enum in place of string literals.
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.
- PinterestSettings: replace inline error markup with the shared
InputError component (matches the rest of the form patterns)
- Extract PinterestBoard type to @/types and use it across
PinterestSettings, ScheduleTab, PostEditorSidebar, Edit — drops
the duplicated inline {id, name} shape from 4 call sites
- Block publish/schedule when Pinterest is selected but no board is
picked (mirrors tiktokComplianceValid). Tooltip surfaces the same
i18n key the backend uses, so frontend + backend speak the same
language
The post editor lost the Pinterest board picker during a UI rewrite,
causing scheduled posts to fail in production with 'Pinterest board_id
is required'. This restores the picker and locks the contract with
validation + tests so the regression cannot happen silently again.
Backend:
- PostController: pinterestBoards is now Record<account_id, Board[]>
(mirrors the TikTok creator-info pattern); supports multi-account.
- UpdatePostRequest: 'platforms.*.meta.board_id' rule + after-validator
rejects Publishing/Scheduling Pinterest posts without board_id.
Frontend:
- PinterestSettings.vue: Combobox board picker with empty-state warning;
emits update:meta with board_id.
- ScheduleTab / PostEditorSidebar / Edit pass pinterestBoards down by
social_account_id.
Tests (6 new):
- UpdatePostRequestTest: rejects publishing/scheduling without board_id
across pin/carousel/video pin; allows draft without board_id;
pinterest error doesn't block sibling platforms in multi-platform.
- PinterestPublisherTest: publisher throws for carousel + video pin
when no board_id (existing image-pin case kept).
1542 tests passing.
Adds a combobox-style filter to the posts index toolbar so users can
narrow All / Scheduled / Posted / Drafts views by one or more labels.
- `PostController::index` accepts `?labels[]=<id>` and applies
`whereHas('labels', whereIn(...))` (OR semantics across selected labels).
Workspace labels are exposed to the page (sorted by name) and the
selected set comes back under `filters.labels`.
- New `LabelFilter.vue` component reuses the existing Popover + Command
pattern (matching `FontPicker` in the Brand settings page). Trigger
renders the selected `LabelBadge`s inline (mirroring how each post row
already displays its labels): 1-3 shown directly, 4+ shown as the
first three plus a "+N" overflow indicator. Clear button has a
tooltip and `cursor-pointer`, and stops `click`/`pointerdown`/
`mousedown` so it doesn't reopen the Popover.
- Existing search debounce is shared with the new label watcher via a
single `buildFilterUrl` helper. URL is updated with `preserveState +
replace` so the back stack stays clean.
- i18n in en / pt-BR / es: `filter_by_label`, `label_search_placeholder`,
`no_labels`, `clear_label_filter`.
Tests: 4 new index tests covering the labels prop exposure, single-label
filter, multi-label OR filter, and blank-id sanitization. Full suite:
1509 passed, 2 skipped, 0 failed.
Left over from the PostEditorHeader extraction in #26 — the header now
derives its own isPublished from the post prop, and Edit.vue itself
doesn't use it anywhere. ESLint caught it on CI.
Scheduled posts were silently editable: auto-save fired on every keystroke
with status='scheduled', and the backend either silently failed (per-platform
content-length, our new rule) or accepted invalid changes that blew up at
publish time. Editing a queued post is also a race with the publisher job.
UX change: scheduled state now reads-only the editor and exposes one
explicit escape — Unschedule to edit. The post drops to draft, user edits,
re-schedules. Matches Buffer/Hootsuite/Later/Postiz.
Implementation:
- New isLocked computed (= isReadOnly || isScheduled) gates save(),
triggerAutosave(), debouncedSave(), togglePlatform(). submit() stays on
isReadOnly so unschedulePost('draft') can still transition out.
- Editor body wraps in pointer-events-none + opacity-60 when isScheduled,
visually dimming inputs without per-input :disabled plumbing.
- Header swaps to a violet "This post is scheduled / Unschedule to edit"
banner replacing the entire normal action bar. Delete is hidden in
this state — only escape is Unschedule, then act on the draft.
Refactor: extracted the entire header into PostEditorHeader.vue (matches
the PostEditorComposer / PostEditorSidebar family). Single <header> root
with v-if/v-else branches by status. ~85 lines out of Edit.vue.
Cleanup: dropped orphan posts.edit.scheduled_for translation key from
en/pt-BR/es — replaced by the new scheduled_overlay_* keys.
Threads posts over 500 chars were saved + scheduled successfully and only
failed inside the publish job. The frontend already showed the 537|500 badge
but `canSchedule` ignored content length, so Schedule and Post Now stayed
enabled. Backend `UpdatePostRequest` only capped at 63206 (Facebook's max),
not per-platform.
- Add `Platform::contentOverflow()` as the single source of truth and reuse it
from `HasSocialHttpClient::validateContentLength` (publish-time).
- New `ContentFitsPlatformLimits` rule applied to the `content` field on
`App\\UpdatePostRequest`, `Api\\UpdatePostRequest`, and `Api\\StorePostRequest`
via `Rule::when(...)` so drafts are not blocked.
- Rule dedupes per platform (two Threads accounts -> one error) and reports
the platform label, hard cap, and overage via i18n.
- Edit.vue feeds `contentLengthOverflows` into `canSchedule` and lists each
offending platform in `postActionTooltip` using the existing
`getPlatformLabel` resolver.