LinkedIn was the only multi-image platform allowing 20 images; align
maxMediaCount/maxImages/maxFiles to 10, consistent with Facebook,
Instagram and Threads. The AI generation cap (MAX_GENERATED_IMAGES)
was already 10 and is unaffected.
Add direct coverage for the centralized media-type logic: every legacy image/video extension arm of Type::fromExtension, and a new MediaItem DataTransferObject test (fromArray's extension->mime backfill — previously the only zero-coverage branch — plus is* delegation and the null-mime extension fallback). Locks the broad classification lists so a dropped format fails a test.
Media-type detection was duplicated across Media, MediaItem (byte-identical copies), HasMedia::getMediaType, the ContentTypeCompatibleWithMedia rule, and six publishers — each hardcoding MIME prefixes and divergent extension lists (Media's video list even had avi/webm/mkv, contradicting the Type enum's mp4/mov).
Add classify(), fromExtension(), and isGif() to Media\Type as the single source for 'what kind is this?' (broad classification), distinct from fromMime()/allowedMimeTypes() (the strict upload allow-list). Every detector now delegates to the enum; the broad extension lists and MIME prefixes live only there. Behavior-preserving (full suite green); adds direct tests for classify/fromExtension/isGif.
The accounts grid and onboarding both build their connect-card list from cases()->filter(isConnectable()) now, leaving the static enabled() helper without any production caller. Remove it and the two unit tests that only exercised it.
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.
Review follow-ups before QA:
- Editor: getMediaIncompatibilityReason rejects a PDF on non-document content types, so the schedule gate and variant auto-snap match the backend rule (compliance i18n in en/es/pt-BR)
- MCP UpdatePostTool: validate effective content_type vs stored media on schedule, closing the schedule-without-content_type gap; share entriesForUpdate/errorsFor with the API path
- Tests: Platform allowedMediaTypes contains Document, URL-attach of a PDF (LinkedIn ok / TikTok rejected), multi-platform PDF rejection, document init-failure/missing-URN, Page publisher PROCESSING_FAILED
Make the document (PDF) exclusivity validation — previously web-only — also apply when scheduling/publishing via the public API and MCP, so a misconfigured post can't slip through these entry points.
- ContentTypeCompatibleWithMedia: stored-media fallback for partial updates + a stored-state assertStoredPostCompatible(Post)
- MCP PublishPostTool: assert stored-state compatibility before publish (the media-side mirror of assertStoredPostPublishable)
- API UpdatePostRequest: validate each platform's effective content_type against effective media on schedule/publish (covers publishing without resubmitting content_type)
- MCP UpdatePostTool: apply the rule on schedule with stored-media fallback
- Tests: API + MCP happy + rejection paths, plus rule fallback/precedence units
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.
Completes the persona grid to a 3x3 with two high-value segments:
Marketer (in-house marketing / social media, a scheduling tool's core user)
and Online store (e-commerce). Labels in all three locales, with
IconSpeakerphone and IconShoppingBag glyphs. Other stays last.
Adds a Developer option to the onboarding persona step (after Freelancer),
with labels in all three locales (Developer / Desarrollador / Desenvolvedor)
and an IconCode glyph in the persona grid. The option flows to the frontend
via Persona::cases() and is accepted by the existing enum validation.
A Bluesky post embed is images XOR video, so a post that carries both can't
be published there. Enforce it per selected platform:
- Add ContentType::supportsMixedMedia() (false only for BlueskyPost) and reject
image+video in ContentTypeCompatibleWithMedia when the type forbids it.
- Mirror it client-side (useMediaRules forbidsMixedMedia + usePostCompliance)
so the editor blocks scheduling with an inline reason before submit, like
every other per-platform compatibility check.
- Add the no_mixed_media message in all three locales (en/es/pt-BR).
- Cover the rule and enum, including the GIF-counts-as-image case.
- 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
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.
All customers were migrated to the single Workspace plan and the old plan rows
were deleted in production, so drop the now-dead legacy tiers from the code:
reduce the Plan Slug enum to Workspace only and default the PlanFactory to it.
Rework StripeEventListenerTest around the single Workspace plan (its monthly and
yearly price ids still exercise plan-by-price mapping, trial clearing, deletion,
and previous-plan propagation), and point the remaining tests that referenced
the starter/pro slugs at the seeded Workspace plan.
The remove/role-change guards only protected the account owner, so a non-owner
admin could change their own role or remove themselves via a crafted request
(the UI hides it, but the backend didn't). Add an explicit self-guard to both
updateRole and removeMember.
Lock the whole role system with tests: accept assigns the exact invited role
(viewer/admin/member), invite requires and persists a role, updateRole supports
viewer and blocks self/owner/invalid, removeMember blocks self/owner, and a
viewer is read-only (view yes; create post / manage team / invite no).
Pennant: all feature flags were replaced by BillingCycle, so remove the package
(composer), the now-empty app/Features discovery, the pennant-development skill,
and replace the create_features_table migration with a drop_features_table.
Timezone: the registration flow collected a user timezone (seeder, request
validation, hidden field) but no timezone column ever existed and CreateUser
discarded it. Remove the dead handling, the orphaned Timezone rule, and the
tests that covered the now-removed validation.
Credit allotment is now derived directly from BillingCycle::for($account)->creditAllotment()
instead of a cached Pennant feature, removing the dynamic cache-invalidation footgun
(forgetPlanFeatureCache) that had to be called from every subscription/workspace mutation.
Bug fix
- Surface the localized "network already connected" message on the
Facebook/Instagram/InstagramFacebook/LinkedInPage/Threads/YouTube OAuth
callbacks: catch NetworkAlreadyConnectedException before the generic catch
so the conflict no longer falls through to a generic error + Log::error.
Scope / dead code
- Remove the orphaned BillingController::checkout() + app.billing.checkout route
(onboarding starts checkout directly); delete the now-dead DiscordWidget and
useFeatureAccess composable; prune orphaned i18n keys left by removing the
plan picker / upgrade dialog / count limits (billing.subscribe.*,
accounts.limit_reached, workspaces.limit_reached, common.discord.*).
- Drop the unused `plan` prop from the usage page and the unused `label` from
the onboarding persona payload (labels come from i18n); remove
Persona::options()/label().
Conventions
- declare(strict_types=1) on the two new migrations.
- Extract autofill validation into AutofillBrandRequest (FormRequest).
- Drop the unused $plan param from AccountPolicy::swapPlan.
- CreateUser: drop the stale config('cashier.trial_days', 7) fallback (now 8).
- Rename LimitEnforcementTest to InvitePermissionTest; use Pest mock() helper in
WorkspaceQuantitySyncTest; move shared test helpers into Pest.php.
- Memoize BillingCycle window() + subscription lookup.
Pricing
- Bill per workspace ($12/mo or $120/yr each); Stripe quantity tracks the
workspace count and syncs on workspace create/delete.
- 2,500 AI credits per workspace, pooled at the account level; monthly reset
on the billing anniversary, annual granted upfront (no rollover).
- One social account per network per workspace; remove all count-based limits
(workspace/social/member) and the legacy plan tiers (single Workspace plan).
Onboarding (cloud only: SELF_HOSTED=false + PostHog)
- Replace the /subscribe plan picker with /onboarding persona selection
(Creator/Freelancer/Startup/Agency/Small business/Other), saved on the user
(users.persona) and mirrored to PostHog, then Stripe Checkout on the monthly
price. 8-day trial so Stripe displays 7.
Billing screen
- Remove the Change Plan dialog (dead with a single plan); add an annual-upgrade
banner for monthly subscribers (swapToYearly).
- Current-plan card shows the workspace count instead of the plan name.
System AI
- Brand analyzer / workspace autofill is always allowed and never debits credits
(system feature, not the user's usage).
Self-hosted (SELF_HOSTED=true) bypasses all billing, credit, limit, network,
and onboarding logic.
Replace the 'single'/'carousel' string format threaded through the AI
generation path with a typed GeneratorFormat enum (PostContentGenerator,
PostContentHumanizer, PostContentStreamer, StreamPostCreation,
RunGenerateNode, deriveFormat).
Remove generatorFormat() from AiContentTemplate and the three templates —
it conflated style and format (returning 'tweet_card'). The tweet-card
check now lives on ContentStyle via isTweetCard(), and the humanizer skip
on ContentStyle::humanizes(), so the style declares its own behaviour
instead of callers comparing magic strings.
Move the RSS sample feeds out of inline heredocs/constants in FetchRssNodeTest
into tests/fixtures/feeds/, matching the parser fixtures. Centralize the
feedFixture() loader in tests/Pest.php and use it across the feed tests.
Replace the RSS-2.0-only SimpleXML parser with SimplePie so the Fetch RSS
node reads Atom 1.0 (YouTube, GitHub, The Verge…) and RSS 2.0 + namespace
extensions (dc:, content:, media:, yt:, itunes:). Each item exposes stable
cross-format aliases (title, link, date, content, author, …) plus every
namespaced field flattened for use as {{ fetched.* }}.
Add a feed-inspection endpoint that discovers a feed's real fields and feeds
them into the editor's expression autocomplete. Allow {{ }} expressions in the
feed URL via a ResolvableUrl rule. Parsing moves to a dedicated FeedParser
service; the fetch keeps the SSRF guard and gains XXE-safe parsing.
Add the five automation models to Relation::enforceMorphMap(), document the
all-models-must-be-mapped rule in CLAUDE.md, and add MorphMapTest to fail
when any app/Models class is missing from the map.
A past_due subscription made subscribed() return false (Cashier default),
so EnsureAccountReady redirected to /subscribe — which starts a brand-new
checkout and creates a second subscription. Past-due users already have a
subscription; they only need to update their payment method.
Enable Cashier::keepPastDueSubscriptionsActive() so past_due counts as
active and users keep navigating. Surface a past-due notice in the sidebar
footer linking to the Stripe billing portal (not /subscribe). Both trial
modes (subscription trial / generic trial) are unaffected.
Add a unit test asserting each BlueskyLexicon constant equals its AT Protocol
NSID — a wrong value (not just a typo'd name) would otherwise fail silently at
runtime. Add a test that a URL with a matching '(' keeps its trailing ')'
(Wikipedia-style), covering the previously-untested keep branch of the trim.
A webhook node parses its payload template as JSON before resolving
placeholders, so a template with unquoted {{ }} placeholders or any malformed
JSON could be saved, tested, and activated — only to fail midway through a run.
Reject it up front instead: AutomationConfigValidator is the single source of
truth for per-node config issues (keyed to the field the editor surfaces them
under), enforced on save (field errors), on activate, and before a test run.
The editor mirrors the check to disable Test/Activate with a clear reason, and
the test panel now surfaces the server's message instead of a generic toast.
resolveVariable() declares a string return type, but json_encode returns
false on malformed UTF-8 (plausible for scraped feed/HTTP payloads),
which throws a TypeError under strict_types and fails the node. Encode
with JSON_PARTIAL_OUTPUT_ON_ERROR and fall back to an empty string.
Replace free-text brand_tone/brand_voice_notes with a single structured
brand_voice_traits JSON column backed by the BrandVoiceTrait enum, exposed
as choice-chip pills in the brand settings UI and autofillable from a site.
Brand voice and visuals become per-automation toggles on the Generate node.
Unify the image controls into one 0-10 picker (0 = text-only, 1 = single,
2+ = carousel) and feed the generator the most restrictive selected network
so copy fits every platform. Pass that same platform context through the
humanizer pass — extracted into a shared ResolvesPlatformCopyBudget trait —
so the rewrite can no longer drift past the character cap the generator
respected, in both the automation and manual creation flows.
Persist the trigger node's schedule editor fields on save (they were
silently dropped by validated() for lacking validation rules).
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.
Review follow-ups:
- AspectRatio::toFloat() now owns the crop ratio math; CropsImageForAspectRatio
delegates to it so the enum is the single source of truth for both validation
and cropping (no more parallel literal map).
- API update controller now reloads postPlatforms before returning, so the
update response reflects the persisted platform meta/content_type (was stale).
- Tests: AspectRatio enum unit test; API valid-update read-back + 'original'
on create; MCP response read-back + valid update.
Instagram carousels were stored as content_type=instagram_carousel, which the
publisher's match() did not handle — publishing failed with "Unsupported
Instagram content type: instagram_carousel" for any post created via API, MCP,
or template (the AI flow worked only thanks to an inline band-aid that rewrote
carousel to feed before saving).
A carousel is just an Instagram feed post with multiple images: the editor,
preview, and publisher already treat a multi-image feed as a carousel. So
instagram_carousel is a generation format, not a stored content type. Remove it
from the ContentType enum entirely; it now lives only as an AI generation-format
string (wizard card + slide structure + carousel templates are untouched), and
posts always persist as instagram_feed.
- ContentType: drop the InstagramCarousel case; InstagramFeed maxMediaCount 1 -> 10
- StreamPostCreation: resolvedContentType() maps carousel -> feed; band-aid removed
- StartPostCreationRequest: accept instagram_carousel as a generation format
- Frontend: carousel becomes a wizard-local AiFormat; UI/UX unchanged
- API/MCP now reject instagram_carousel as a content_type (Rule::in no longer lists it)
- 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.
Facebook Stories do not work reliably with images via the Graph API.
Align validation and publisher with video-only (like Reel), while
Instagram Story continues to accept image or video.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Introduced a new `regenerate_image` boolean parameter in the PostImageRegenerator schema to control background image regeneration.
- Updated relevant methods in RegeneratePostMediaImage and TemplateImageGenerator to utilize the new parameter for reusing existing backgrounds when appropriate.
- Enhanced documentation and comments to clarify the usage of the `regenerate_image` option.
- Added tests to ensure correct behavior when reusing background images.
- Removed the PostStatusGuard class and replaced its usage with the new PostStatusRules utility across multiple controllers and actions, enhancing code organization and maintainability.
- Updated error message handling to utilize the centralized method in PostStatusRules, ensuring consistency in user feedback.
- Deleted associated tests for PostStatusGuard, reflecting the removal of the class.
- Replaced direct status checks in multiple controllers and actions with the PostStatusGuard utility, improving code readability and maintainability.
- Updated error messages to utilize a centralized method for consistency across the application.
- Removed the BrandImagePalette class, consolidating color resolution logic into the AiImageClient for better organization and type safety.
Let users adjust AI-generated slides in place via async job and Echo, while applying workspace brand, background, and text colors to image prompts. Autofill swaps site text/background colors for the image palette, and regeneration is blocked on finalized posts with safer job cleanup.
Co-authored-by: Cursor <cursoragent@cursor.com>
Add a trypost config toggle to switch between card-required checkout trials and no-card signup trials, and wire signup, checkout, access gating, UI copy, and tests to both modes.
Co-authored-by: Cursor <cursoragent@cursor.com>
Revert the no-card signup trial flow so access depends on a Stripe subscription trial started at checkout, preventing app access before a payment method is collected.
Co-authored-by: Cursor <cursoragent@cursor.com>
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.
- TokenRedactorTest (7 unit tests): all four regex patterns, multiple
secrets in one body, null input, and the no-op pass-through case.
Guards against silent regression of the redaction regexes (which
previously drifted across three duplicated copies).
- ConnectionVerifierTest: HTTP 429 during refresh raises
PlatformUnavailableException, not TokenExpiredException. Locks in
the rate-limit-as-transient behavior added when consolidating the
refresh logic.
- StripeEventListener::handleSubscriptionCreated nulls account.trial_ends_at
when a Stripe subscription is created. Prevents 'Trial' badge from
lingering for users who convert mid-generic-trial to paid.
- Drop unused trialDays global Inertia prop (no frontend consumers after
/subscribe redesign).
Tests added (10 new, 0 regressions, 1533 total):
- AccountTest: isOnTrial + activeTrialEndsAt across 4 scenarios
(no trial, generic only, subscription only, both — subscription wins)
- StripeEventListenerTest: subscription created clears generic trial
- TrialMiddlewareAccessTest: trialing-with-card subscription passes
- BillingControllerTest: index exposes onTrial/trialEndsAt for the 3
trial states (generic-only, subscription-only, paying); subscribe
page no longer exposes trialDays prop
Production was returning HTTP 413 with empty body on the first APPEND
segment of every video upload to X v2 — surfacing to users as 'An
unknown X error occurred.'
Empty-body 413 is the classic signature of an edge/CDN rejection: the
X gateway is denying the request before X's application code sees it.
The X v2 reference docs say 'max chunk size: 5MB', but every canonical
reference uses 1MB:
- X's official Python quickstart: `chunk_size = 1024 * 1024`
- X's official JavaScript quickstart: `const chunkSize = 1024 * 1024`
- twitter-api-v2 (the most-used Node SDK, used by Postiz et al.):
`chunkSize: number = 1024 * 1024`
5MB plus multipart-form overhead apparently exceeds an undocumented
edge limit. 1MB is the empirically safe size everyone converges on.
Changes:
- XPublisher chunked APPEND now uses 1MB chunks. An 8MB video uploads
as 8 segments instead of 2; more roundtrips but actually succeeds.
- Set explicit Content-Type on each chunk attach (matches the simple
upload path in the same file).
- XPublishException::fromApiResponse maps HTTP 413 to
ErrorCategory::MediaFormat with the message 'Media chunk rejected
by X (payload too large).' so we don't surface 413 as 'unknown' if
it ever recurs.
Test: unit test covering the 413→MediaFormat mapping. Full suite green
(1503 passed, 2 skipped).