* Remove the automations module
Drops the visual workflow builder end to end: actions, node runners,
commands, jobs, models, observers, policy, resources, requests, routes,
broadcast channel, scheduler entries, Horizon supervisor, Vue pages and
components, canvas undo/redo history, CodeEditor, translations, factories
and tests.
A new migration rewrites posts.created_via = 'automation' to 'web' and
drops the five automation tables. The CreatedVia::Automation enum case is
removed. The 2026-08-21 social-account identity migration now skips its
automation node repointing when the automations table no longer exists,
so it stays re-runnable after the drop.
Orphaned dependencies removed: simplepie/simplepie, @vue-flow/*,
codemirror and @codemirror/*.
* Drop the orphaned common.beta translation key
* Remove automation leftovers: ResolvableUrl rule, feed fixtures, useShortcut, nav badge
* Drop the unused chart wrapper and @unovis packages
* Address review: keep the shipped migration untouched, drop the dead previewOnly chain
- Restore 2026_08_21 migration to exactly what production ran; the
rehearsal test now recreates the automations table it expects instead.
- Mark the drop migration's down() irreversible like its siblings.
- Simplify DropAutomationTablesMigrationTest to the sibling shape.
- Remove previewOnly / aiGenerateVariants: the only caller that set the
prop was the deleted automation Generate node.
- Run pint over lang/*/common.php after the beta key removal.
* Exercise the drop migration against the real automation tables and their FKs
* Drop DuplicateIdentityRehearsalTest: it re-ran a frozen migration that reads the removed automations table
X bills a post containing a URL at a much higher rate than a plain post, and
its algorithm demotes link posts. The X version of a post now rewrites every
URL non-clickable (https://example.com/post becomes example(.)com/post):
scheme and www. dropped, every dot of the host replaced with (.).
Leaving a single dot intact would still leave a resolvable domain for X to
detect, so all of them are broken. A scheme or www. proves a token is a URL on
its own; a bare host only counts when its last label is a delegated TLD, which
is the one thing telling acme.com apart from Node.js. That check runs against
App\Support\LinkTlds, generated from the whole IANA root zone in every form a
TLD can appear in a post -- ASCII, punycode and the Unicode it decodes to --
because whatever X links is what X bills, so a hand-picked subset would leave
us paying for its gaps. If the regex engine bails out on pathological input the
original content is returned instead of crashing the publisher.
The transform lives in the Platform::X arm of ContentSanitizer, so it reaches
publishing and the app/API/MCP previews from one place and cannot touch any
other network. Off by default; opt in with X_DEFUSE_LINKS.
The editor counts characters and renders its preview client-side and cannot ask
the server on every keystroke, so the rewrite is mirrored in TypeScript. PHP
stays the source of truth: a parity test fails if the two TLD sets drift, and a
browser test drives the real editor so the mirror is covered rather than
assumed. Without it the composer promised text the network never receives.
Character limits now measure the text a reader will see: sanitized, then with
markup resolved away. Measuring the raw draft blocked saving posts that publish
fine and let through posts the network rejects, and counted the editor's HTML
toward the limit. Measuring the sanitized form alone would have counted
Telegram's escaped entities, rejecting messages Telegram accepts.
Empty content is handled once inside the sanitizer instead of by a guard
repeated at every call site.
* 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>
* 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>
AI generate only produces images, so Video Pin / Reel / TikTok Video are filtered out in previewOnly and rejected server-side with a clear error.
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>
The scheduled-post banner in the editor now clears the floating sidebar
trigger on mobile (pl-12 md:pl-0), aligns its calendar icon to the top of
the text, and matches the hamburger's size (size-10 md:size-9). Desktop is
unchanged.
- 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.
Match the switcher tab height to the sidebar trigger (h-10), and let the primary
publish button fill the footer (flex-1) on mobile while staying auto-width on desktop.
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.
The scheduled and editable states each rendered their own copy; the actions
component already decides internally what to show, so it only needs one instance
pushed right with ml-auto.
The 380px mockup frame is wider than a phone viewport, so it sat edge-to-edge. Scale it to 90% below lg (origin top-center keeps it centered) so there's clean symmetric padding to the screen edges. Reverts the now-redundant px-2 container tweak back to px-4.
- Restore desktop tab spacing (lg:gap-6) on the automation tabs
- break-words / viewport width caps on the AI review chips, lightbox alt overlay, and pick-time popover
- Remove the now-unused hoveredCommentId state after the comment toolbar became touch-visible
- NotificationBell panel was fixed at left-[17rem] w-[22rem] and ran off-screen on a phone; now full-width-with-margins below sm
- Reserve top space for the floating sidebar trigger so page headings no longer sit under the hamburger on mobile
- automations/Index header stacks on mobile (was the one index page the first pass missed)
- Contain the preview phone mockup horizontally so it never breaks the page on narrow screens
- Comment bodies wrap long unbroken tokens (break-words)
- 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.
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()
Selecting media in the gallery dropped original_filename, size, and meta when building the picked item, so they never reached the post. Since the gallery is the only way to add media, this left PDF documents without a filename (empty title field, 'PDF' thumbnail label, 'Document' fallback on publish) and skipped size/duration validation for all gallery media. Carry those fields through (guarded for unsplash/giphy items that lack them).
LinkedIn settings module: render only when a PDF is attached (its only relevant config now that format is inferred from media), pre-fill the document title from the PDF filename (editable), drop the redundant media warning (already surfaced on the channel) and the unused contentType prop/binding.
LinkedInSelect: add Person/Organization tag icons. i18n: clearer document-not-alone message in all locales.
Collapse LinkedIn to one content type per account kind (linkedin_post, linkedin_page_post). Publishers infer the publish format from the attached media — text, single image/video, multi-image carousel, or PDF document — matching how facebook_post/x_post already work; PDF is exclusive of any other attachment. Removes the editor variant picker, keeping only the PDF document title field. Includes a data migration collapsing the retired carousel/document content types.
Replace the two LinkedIn account cards with a single Connect LinkedIn button: one unified OAuth grant (linkedin-openid driver, union of scopes) then a post-callback identity picker to post as the personal profile (linkedin) or a company page the member administers (linkedin-page). The chosen organization is validated against the admin-verified list from the OAuth grant. Per-capability gating via LINKEDIN_ENABLED / LINKEDIN_PAGE_ENABLED supports profile-only or org-only self-hosting. Removes LinkedInPageController, LinkedInTokenSynchronizer, the standalone linkedin-page connect routes, and the unused redirect_page config.
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.
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.
Persist channel_name (display-only) so the preview header shows the chosen
channel; render mentions as pills; restyle the preview to a light theme to
match the rest of the app.
- Introduced caching for Discord channel lookups, reducing API calls and improving performance.
- Updated the DiscordSettings component to use a SearchableSelect for better user experience when selecting channels.
- Added new language strings for channel search and no channels found messages in English, Spanish, and Portuguese.
- Updated tests to ensure channel list caching is isolated between runs.
- Add a placeholder discord.png so the brand mark isn't a broken image
(operators can swap in the official logo).
- Keep a saved Discord channel selectable before the live channel list loads
(or if the lookup is unavailable), so editing a post never loses the channel.
- Cover the media multipart upload path (payload_json + files[N]) with a test.
Connect a Discord server via OAuth (bot authorization) and schedule/publish
messages to its channels, with mentions and rich embeds.
- Connect: custom Socialite Discord provider (bot scope) maps the authorized
guild to a SocialAccount; throws if no server was authorized.
- Publish: DiscordPublisher posts via the global bot token, validates the chosen
channel belongs to the connected guild (anti cross-guild), optimizes media,
builds allowed_mentions only from explicit mention chips (no accidental pings),
and renders rich embeds.
- Compose: per-post channel picker (live lookup), mention autocomplete and an
embed editor, gated by a required-channel compliance rule; Discord post preview.
- Enum/config/content-type wiring, ConnectionVerifier health check, throttled
lookup endpoints, i18n (en/es/pt-BR), and tests.
Operators must create a Discord application and set DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN and DISCORD_CLIENT_REDIRECT.
- Added support for workflow variables in automations, allowing users to define reusable values.
- Implemented validation for Generate nodes to ensure intended image counts align with selected accounts.
- Updated automation models and requests to handle new variables, including encryption for sensitive data.
- Enhanced UI to display variables and their management within the automation editor.
- Improved error handling for webhook and HTTP nodes to prevent requests to invalid URLs.
- Refactored various components for better context resolution during automation runs.
Conflict resolutions + integration fixes:
- CreatePost: kept the branch's merge-into-existing meta persistence (equivalent
to main's #86 replace on create, and what the automations flow was built on).
- FacebookSettings.vue: kept both new defaults (previewOnly + meta).
- RunGenerateNode + GenerateNodeConfig.vue: ContentType::InstagramCarousel was
removed on main (#80); an IG carousel is now a multi-image instagram_feed, so
the carousel-capable list uses InstagramFeed.
- GenerateNodeTest: fixtures use the ContentType enum and the new instagram_feed
carousel signal.
The tab holds publish targets, per-platform settings, and labels — not
scheduling (the date/time control lives in the editor header). Renames the
i18n key posts.edit.tabs.schedule -> tabs.channels (Channels / Canais /
Canales) and the single $t reference. The scheduling button string and the
tab's internal value are untouched.
Facebook feed posts now have the same aspect ratio selector as Instagram
(1:1, 4:5, 16:9, Original), letting users control how their image is cropped
before publishing instead of leaving it to Facebook's auto-crop.
The Facebook Graph API has no crop/aspect-ratio parameter for organic Page
photo posts (image crops are ads-only) — Facebook displays the bytes it
receives at their native ratio. So, exactly like Instagram, the crop is done
server-side before upload.
- Extract the Instagram crop logic into a shared CropsImageForAspectRatio
concern (download -> crop -> store -> URL), with a per-publisher
cropFailureException hook. InstagramPublisher refactored to use it
(behaviour identical; existing tests unchanged).
- FacebookPublisher reads meta.aspect_ratio and crops single- and multi-image
feed posts (facebook-crops/ prefix). Reels/Stories are unaffected.
- FacebookSettings: aspect ratio picker on the Feed variant (default Original
-> no crop). ScheduleTab wires meta; FacebookPreview reflects the chosen
ratio. Adds posts.form.facebook.aspect.* in en/es/pt-BR.
Closes#69
Match the literal copy the TikTok app reviewer looks for during the
Content Posting API audit, per the Mixpost playbook (the only public
account of how to pass).
- Privacy dropdown placeholder: "Select who can view this post"
- Tooltip on interaction toggles disabled by creator: "Turned off in
your TikTok account settings"
- Render the "may take a few minutes" notice that was translated but
never displayed (processing_hint)
Mirror both i18n changes in pt-BR and es. Add a feature test that locks
the literal strings so they cannot drift before the next review cycle.
- Added new automation-related routes and controllers for managing automations.
- Introduced automation nodes in the UI with distinct styles and interactions.
- Updated sidebar to include navigation for automations.
- Enhanced post creation logic to support automation metadata.
- Refactored content type and platform enums into types for better type safety.
- Added localization for automation-related terms in English, Spanish, and Portuguese.
- Improved error handling in various components to accommodate new features.
- Added consistent imports of the MediaItem type across various components, including FacebookSettings, InstagramSettings, LinkedInSettings, PinterestSettings, PreviewTab, InstagramPreview, and PlatformPreview.
- This change enhances code organization and ensures uniformity in type definitions throughout the project.