Pricing
- Bill per workspace ($12/mo or $120/yr each); Stripe quantity tracks the
workspace count and syncs on workspace create/delete.
- 2,500 AI credits per workspace, pooled at the account level; monthly reset
on the billing anniversary, annual granted upfront (no rollover).
- One social account per network per workspace; remove all count-based limits
(workspace/social/member) and the legacy plan tiers (single Workspace plan).
Onboarding (cloud only: SELF_HOSTED=false + PostHog)
- Replace the /subscribe plan picker with /onboarding persona selection
(Creator/Freelancer/Startup/Agency/Small business/Other), saved on the user
(users.persona) and mirrored to PostHog, then Stripe Checkout on the monthly
price. 8-day trial so Stripe displays 7.
Billing screen
- Remove the Change Plan dialog (dead with a single plan); add an annual-upgrade
banner for monthly subscribers (swapToYearly).
- Current-plan card shows the workspace count instead of the plan name.
System AI
- Brand analyzer / workspace autofill is always allowed and never debits credits
(system feature, not the user's usage).
Self-hosted (SELF_HOSTED=true) bypasses all billing, credit, limit, network,
and onboarding logic.
The Generate node data rules had no rule for 'style', so validated()
dropped it on save — the run always fell back to the default image_card.
Add a Rule::in(ContentStyle) rule so the selected style is persisted and
unknown values are rejected.
Add a mini variant that collapses to the selected style and expands into
the list (matching the node config UX), with a larger thumbnail and less
padding so it reads well in the narrow automation panel. The full gallery
on the create page is unchanged.
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.
Regenerate the three style thumbnails so they show a real account
avatar instead of a blank header. Drop the verified.svg source; only
the rasterized verified.png is read at render time.
The humanize() guard was keyed on format string 'tweet_card', but for tweet_card
carousel the format becomes 'carousel'. Changed the guard to key on the style key
($this->template === 'tweet_card') so humanization is skipped for all tweet_card
variants — single and carousel alike.
- TemplateContext gains bool $isCarousel (set from format === CAROUSEL_FORMAT in StreamPostCreation)
- AiContentTemplate interface: promptView() now takes TemplateContext
- ImageCardTemplate handles both single and carousel via context.isCarousel; schema() returns the correct shape per mode; assemble() routes to assembleSingle/assembleCarousel
- TweetCardTemplate handles both single and carousel; promptView() returns tweet_card_carousel view when isCarousel; schema() returns {tweet_text} or {caption, slides[]}; assemble() calls forTweetCard (single) or forTweetCardCarousel (carousel)
- CarouselTemplate deleted — carousel is now a format dimension each style handles, not a standalone style
- AiTemplateRegistry: [ImageCardTemplate, TweetCardTemplate] only
- PostImagePipeline: adds forTweetCardCarousel — renders one renderTweetCard per slide text, returns N media items
- StreamPostCreation: carousel detection restored to $format === CAROUSEL_FORMAT (like main); style dispatched via AiTemplateRegistry::find($this->template); humanize skips tweet_card (single and carousel)
- PostContentGenerator: promptView() call updated to pass templateContext
- New prompt: resources/views/prompts/post_content/tweet_card_carousel.blade.php
- Tests: TemplateContractTest and TweetCardTemplateTest updated for promptView(context) signature; AiTemplateRegistryTest asserts [image_card, tweet_card]; StreamPostCreationTest adds tweet_card carousel test; TweetCardTemplateTest covers both schema shapes and both promptView return values
- Add AiTemplateRegistry (resolves, keys, default)
- Add template/templateContext params to PostContentGenerator; schema() delegates to template when both are set; instructions() uses template.promptView()
- Refactor StreamPostCreation.handle() to resolve template via registry, build TemplateContext, delegate assemble() to template; add createPostFromGenerated()
- Remove handleSingle(), handleCarousel(), resolvedContentType() (no other callers)
- PostAiCreateController passes template param from request (default image_card)
- All existing AI tests green; registry test added
- Assert nested Discord meta keys (mention token/label, embed title/color)
survive validated(), replacing a vacuous coalesced assertion.
- Cover the MCP publish guard for TikTok privacy and Pinterest board, not
just Discord.
- Use assertUnprocessable() to match sibling API tests; type-hint the API
UpdatePostRequest withValidator closure.
Record in CLAUDE.md that all platforms.*.meta validation and required-on-publish
logic lives only in PostPlatformMetaRules, why (validated() strips unlisted keys),
and where to add new per-platform meta + tests.
The shared rules() returned only meta sub-keys, so every caller still repeated
its own 'platforms.*.meta' => array parent (with slightly different sometimes/
nullable combos). Fold the parent into rules() with a single safe contract so
callers just spread one source for the whole meta block.
Broaden the post meta tests beyond Discord: store persists Instagram aspect
ratio, Pinterest board, TikTok privacy + flags; API update enforces TikTok
privacy and Pinterest board on publish and allows drafts without required meta;
MCP update merges meta and the publish guard ignores disabled platforms.
Centralize per-platform PostPlatform.meta validation in PostPlatformMetaRules
(shared by web, REST API and MCP). The API and MCP previously only accepted
aspect_ratio, silently stripping channel_id/board_id/privacy_level and the rest
via validated(), so Discord/Pinterest/TikTok couldn't be configured or published
through those entry points. Now all per-platform meta is accepted and persisted,
required-on-publish is enforced on API update (TikTok privacy, Pinterest board,
Discord channel), and the MCP publish tool guards a post's stored meta before
dispatching. Adds API + MCP tests and a PostPlatform discord() factory state.
Add per-post Discord metrics (server member count, thread replies as comments,
and reaction counts) read live with the bot token via GET message + guild
with_counts, wired into PostMetricsFetcher. Redesign the shared post metrics
row: monochrome line icons for member/comment counts vs clean reaction pills,
so audience stats read distinctly from reactions. Applies to Telegram too.
Only text (0) and announcement (5) channels accept a direct message — forum
(15) and media (16) require thread creation, so they're dropped from the
picker (they returned 'non-text channel'). Channels are also filtered by the
bot's effective VIEW_CHANNEL + SEND_MESSAGES, computed from role base perms and
channel overwrites (Discord's documented algorithm), with a safe fallback to
type-only filtering when the bot's roles can't be resolved.
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.