Commit graph

95 commits

Author SHA1 Message Date
Paulo Castellano
1242cc7384 refactor(posts): centralize inline media validation in PostMediaRules
The media.* rules were duplicated across the web update request and both API
requests (and diverged: web requires hosted id+path and tracks source; the API
accepts a bare external url it downloads). Pull them into one
App\Support\PostMediaRules::rules(hosted:) — same pattern as PostPlatformMetaRules
— parameterized by contract, so there's a single place to add a media key and the
validated()-strips-unlisted-keys footgun can't drift between entry points.

Behavior is unchanged (each ruleset is reproduced exactly). Web store keeps its
loose 'media' => array (no item rules) and is left out on purpose — adding strict
rules there would change the web create contract.
2026-06-28 20:43:09 -03:00
Paulo Castellano
8d7dcdf6eb refactor(social): trim verbose comments + harden X chunked upload from review
Cold-review follow-ups on the PR:
- Trim the oversized docblocks/inline comments added across the API controller,
  MediaAttacher, Post, the publish job, and the X publisher to one line (keeping
  the @param/@return array-shape annotations).
- XPublisher::chunkedUpload now accepts ?string $mediaCategory and only sends
  media_category when present — getMediaCategory() can return null, so the strict
  string param was a latent TypeError (unreachable on X today, removed anyway).
- Fix MediaAttacher docblocks: the file imports Type as MediaType, so the
  @param array<Type> annotations didn't resolve — now array<MediaType>.
- Tests: cover the failed() job hook genericizing a raw error, and X failing
  cleanly (XPublishException) when media can't be downloaded.
2026-06-28 20:37:04 -03:00
Paulo Castellano
81d43c30f4 fix(api): download and host external media URLs on post create/update
The public REST API accepted inline post media as a free-form array and stored
it verbatim, so a client could create/update a post whose media was a bare
external URL we never hosted. Publishing then depended on that third-party URL
staying alive — when it 404'd (e.g. an image proxy), the post failed across
platforms (Facebook 'unsupported media type', X 'HTTP 404', Instagram 'could
not fetch media').

Inline media URLs on create/update now go through the same download + MIME-
validate + host path as the attach-from-url endpoint (MediaAttacher), so the
stored media always points at our own storage. Items already hosted (carrying a
path) pass through untouched. If any URL can't be fetched the request is
rejected with 422 and nothing is persisted, so a post is never created with
broken media. MCP and the web flow were already safe and are unchanged.

- MediaAttacher: extract fetchToWorkspace() + add resolveInlineMedia()
- Post::allowedMediaTypesFor() so the create flow can compute allowed types
  without a persisted post
- API Store/UpdatePostRequest: media.* item rules (mirroring the web; prevents
  validated() from stripping hosted-item keys)
- PostController store()/update(): host external media before persisting
2026-06-28 17:28:05 -03:00
Paulo Castellano
2525d834b0 test(onboarding): cover the full flow; drop goal exclusivity rule
- add an end-to-end walk (account gate -> persona -> goals -> connect ->
  Stripe) and a self-hosted bypass test, plus the connect no-workspace and
  just-exploring-saved cases
- drop the just_exploring exclusivity: the backend now saves any valid goal
  combination (the front-end still clears siblings as a UX nicety), removing
  the withValidator rule, the unused Goal::isExclusive(), and the now-unused
  goals_exclusive copy
2026-06-25 21:01:46 -03:00
Paulo Castellano
afc4c7a80b feat(onboarding): add goal step after persona
After picking who they are, users now pick what they want to achieve with
TryPost. A multi-select goal step (12 options + an exclusive "just exploring"
and "something else") sits between the persona step and connect, mirroring the
persona screen's style.

The goals persist to a json column on users and are mirrored to PostHog on
identify (onboarding_goals array plus a boolean per goal), so campaigns can be
cross-tabbed against the intent they actually attracted. connect now requires
both a persona and at least one goal; persona store advances to the goal step.

Options are grounded in TryPost's real capabilities (publishing, AI content,
brand voice, automation via API/MCP, collaboration, analytics) and copy is
localized in en/es/pt-BR.
2026-06-25 20:49:04 -03:00
Paulo Castellano
c339f8cf28 fix(linkedin): align PDF compatibility across editor, MCP schedule, and tests
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
2026-06-24 17:36:39 -03:00
Paulo Castellano
14e44538a7 feat(linkedin): enforce PDF/media compatibility on API and MCP
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
2026-06-24 17:08:48 -03:00
Paulo Castellano
7388313f5c feat(linkedin): support PDF document (carousel) posts
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.
2026-06-24 16:32:27 -03:00
Paulo Castellano
6c47bac1b8 fix(members): preserve invited role on accept and surface viewer in role menu
AcceptInviteController attached invited users with a hardcoded member role,
ignoring the invite's role entirely (a viewer invite joined as member). Use the
invite's role on accept, require role on invite creation (no silent default),
and drop the role from the request/CreateInvite defaults. Add the Viewer option
to the member role dropdown (now iterates all roles), hide the dropdown on the
current user's own row, and require the member's email to confirm removal.
Covered by tests asserting the accepted role matches the invited role for
viewer/admin/member, plus invite role validation.
2026-06-22 14:24:19 -03:00
Paulo Castellano
0619f2a0f4 refactor: address code review for per-workspace pricing
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.
2026-06-21 21:28:47 -03:00
Paulo Castellano
cbf8fb283c feat: per-workspace pricing, onboarding, and billing overhaul
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.
2026-06-21 20:40:03 -03:00
Paulo Castellano
31de750c71 fix(automations): persist the Generate node content style
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.
2026-06-18 11:50:10 -03:00
Paulo Castellano
0574560570 feat(ai-templates): ContentStyle enum as the typed source for the 3 styles 2026-06-17 19:52:38 -03:00
Paulo Castellano
8c72719f92 feat(ai-templates): validate template key + require account when needed 2026-06-17 17:27:58 -03:00
Paulo Castellano
40425d7bdc test,refactor: review fixes for per-platform meta parity
- 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.
2026-06-16 18:11:29 -03:00
Paulo Castellano
ba20534a6d refactor(meta): own the platforms.*.meta parent rule in PostPlatformMetaRules
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.
2026-06-16 17:50:40 -03:00
Paulo Castellano
bdea56d5e1 feat(api,mcp): full per-platform meta parity for posts
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.
2026-06-16 17:39:07 -03:00
Paulo Castellano
df81532c67 feat(channels): show channel + mentions in Discord preview, light theme
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.
2026-06-16 16:14:59 -03:00
Paulo Castellano
c2dd4515b2 feat(channels): add Discord as a social channel
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.
2026-06-16 14:44:00 -03:00
Paulo Castellano
45da024fc6 fix(automations): code-review hardening for feed fetch & URL validation
- inspectFeed fetches via SafeHttpFetcher::get (SSRF guard + timeout + redirect
  cap + UA) instead of a raw, timeout-less Http::get; add a timeout to the node fetch.
- ResolvableUrl now requires an http(s) scheme, so it's no weaker than the plain
  url rule it replaced (rejects file://, javascript://).
- Apply ResolvableUrl to the HTTP Request and Webhook node URLs too, so templated
  {{ }} URLs validate consistently across every URL field.
- Clear a fetch_rss node's discovered_fields when its feed_url changes, so stale
  fields from the previous feed stop showing up as autocomplete suggestions.
2026-06-16 12:09:16 -03:00
Paulo Castellano
246a159f34 feat(automations): multi-format RSS/Atom feeds with dynamic variables
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.
2026-06-16 10:56:08 -03:00
Paulo Castellano
2bd2e72656 Validate webhook payload template is JSON before it can run
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.
2026-06-13 16:03:19 -03:00
Paulo Castellano
605261e1b8 Back fixed-set automation strings with enums and consts
Replace magic strings across the automation domain with backed PHP enums
(HttpMethod, AuthType, DelayUnit, ScheduleField) and mirrored TS consts
(http-method, auth-type, delay-unit, schedule-field, condition-operator,
publish-mode), plus the existing Condition\Handle / Operator / Publish\Mode.

Also:
- require scheduled_offset via concrete-index required_if instead of
  defaulting to 60 when the publish mode is scheduled
- fail the webhook node explicitly when the resolved url is empty
- localize node failure messages (fetch_rss/http/webhook)
- cast resolver/strtoupper inputs to string so a present-null config value
  degrades gracefully instead of crashing
- list automations with config('app.pagination.default'), drop the perPage param
2026-06-13 15:04:24 -03:00
Paulo Castellano
478cad9f27 Tighten automation conventions: pagination, imports, i18n, enums
- Pagination: drop the perPage override from ListAutomations and the
  hardcoded page size from GetAutomationInvocations; both use
  config('app.pagination.default'). Document the rule in CLAUDE.md.
- Imports: import DomainException / InvalidArgumentException / Throwable
  instead of inline backslash references across the automation actions.
- i18n: move the hardcoded Fetch RSS and HTTP Request failure strings to
  automations.errors.* in all three locales.
- Publish: require scheduled_offset when mode is scheduled (validation)
  and drop the magic 60-minute default in the node. Fixes the required_if
  rules to reference the concrete node index instead of a wildcard that
  never resolved (also repairs the trigger cron rule).
- Condition handles: back the yes/no output handles with a shared
  Condition\Handle enum (PHP) and ConditionHandle const (TS).
2026-06-13 13:59:52 -03:00
Paulo Castellano
37a7a64ff0 Drop the custom-cron schedule option from automations
Remove the "Custom (Cron)" schedule field — too technical for the editor.
The remaining presets (minutes/hours/days/weeks/months) cover the need and
still build the cron string under the hood.

Removed at the root: the ScheduleField.Custom enum case, the custom-cron
input and select option, the schedule_custom_cron type field and its
schedule-summary handling, the backend validation (Rule::in and the
schedule_custom_cron rule), the i18n keys, and the custom round-trip test
case. Existing automations keep firing — the scheduler runs off the stored
cron string, not schedule_field.
2026-06-13 10:06:38 -03:00
Paulo Castellano
a09b1f45c2 Structure brand voice and make generated copy platform-aware
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).
2026-06-12 17:09:39 -03:00
Paulo Castellano
9a692b4608 Enhance automation functionality: Introduce workflow variables and improve node validation
- 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.
2026-06-11 15:47:29 -03:00
Paulo Castellano
af4d83190e Move automation reads and delete into actions 2026-06-10 18:10:13 -03:00
Paulo Castellano
21b14f7893 Merge main into feat/automations-module
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.
2026-06-10 17:09:54 -03:00
Paulo Castellano
ce6ee04883 Bring post platform meta (aspect ratio) to parity across API and MCP
PR #82 made `meta.aspect_ratio` crop Facebook (and already Instagram) feed
images at publish time, but the API and MCP surfaces only half-supported it:
you couldn't set meta at creation, the value wasn't validated, and responses
never returned it. This closes those gaps.

- New `AspectRatio` enum is the single source of truth for the allowed ratios
  (1:1, 4:5, 16:9, original). App/API/MCP requests now validate via
  `Rule::enum(AspectRatio::class)` — an invalid ratio is rejected everywhere
  instead of silently center-cropping to square.
- API `StorePostRequest` and MCP `CreatePostTool` now accept `platforms.*.meta`;
  `CreatePost` persists it. MCP create documents `meta` in its schema.
- `Api\PostPlatformResource` now exposes `meta`, so API and MCP responses return
  the aspect_ratio (and other per-platform meta) a client set.
2026-06-10 15:36:47 -03:00
Paulo Castellano
cca7893e0f Stop persisting instagram_carousel as a content type
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)
2026-06-04 18:10:39 -03:00
Paulo Castellano
2f047f2f7e Fix workspace name-only update rejected by required brand fields
The Workspace and Brand settings tabs both submit to the same
updateSettings endpoint, but the Workspace tab sends only the name.
UpdateWorkspaceRequest marked brand_font and image_style as required,
so the name-only request failed validation silently (the form only
renders the name error). Mark both brand fields as sometimes|required
so a name-only update succeeds while the Brand tab still validates them.

Closes #74
2026-06-02 08:45:11 -03:00
Paulo Castellano
b23ab0166e feat(automations): implement automation features and UI enhancements
- 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.
2026-05-24 09:17:19 -03:00
Paulo Castellano
560393db2a feat: regenerate AI post images with brand palette in editor
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>
2026-05-21 16:27:55 -03:00
Paulo Castellano
7341852f5c refactor(mcp): move upload config from trypost.mcp to ai.mcp 2026-05-15 16:25:26 -03:00
Paulo Castellano
6b40edce55 refactor(mcp): extract MCP upload caps to trypost.mcp.upload config 2026-05-15 16:24:14 -03:00
Paulo Castellano
d50348562b refactor(media): atomic upload_token via transaction and tighten test assertions 2026-05-15 16:16:42 -03:00
Paulo Castellano
b17026f3a1 feat(media): signed POST upload endpoint for MCP flow 2026-05-15 16:10:21 -03:00
Paulo Castellano
44d891ef08 fix(pinterest): restore board picker + require board_id in validation
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.
2026-05-15 11:51:20 -03:00
Paulo Castellano
b10f25f94d refactor(posts): tighten content-length caps and simplify contentOverflow
- Platform::contentOverflow returns int (0 = fits) via max(), drops the
  nullable-int + ternary pattern; HasSocialHttpClient and the validation
  rule updated to compare against 0 instead of null.
- Drop the hardcoded 63206-char limit to 10000 across all entry points
  (Platform enum, MCP CreatePostTool/UpdatePostTool, FacebookRules, all
  three FormRequests). Facebook's API accepts up to 63206 but nobody
  writes 63k-char posts and emoji-heavy content risks overflowing the
  TEXT column's 65535-byte ceiling.
- Compiled i18n JSON regenerated with the content_exceeds_platform key.
2026-05-11 20:06:44 -03:00
Paulo Castellano
953be22b5b fix(posts): block scheduling when content exceeds any platform's char limit
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.
2026-05-11 19:39:41 -03:00
Paulo Castellano
b322a7074f refactor(posts): drop hardcoded English validation messages
Removes the messages() override on UpdatePostRequest. The 4 messages it
defined were hardcoded English strings that bypassed Laravel's built-in
validation translations. With the override gone, errors flow through
lang/{locale}/validation.php which is already translated for en/pt-BR/es.
Trade-off: default messages reference the field path (e.g.
'platforms.0.content_type'). Acceptable here because the editor renders
errors inline next to each field rather than as a flat list.
2026-05-09 13:58:09 -03:00
Paulo Castellano
c5e667f92a refactor(tiktok): use i18n for content type description and validator error
- ContentType::description() now reads from posts.content_types.{value}.description
  instead of hardcoded English. Adds the missing tiktok_photo entries in en/pt-BR/es
  and syncs three Pinterest descriptions that had drifted between the i18n file and
  the previously-hardcoded enum strings (the enum strings were the user-visible source).
- UpdatePostRequest::withValidator surfaces the privacy_required validation error
  via posts.form.tiktok.privacy_required (added in en/pt-BR/es) instead of an
  English string.
- Drops a verbose 3-line comment in withValidator that explained obvious code.
2026-05-09 13:56:00 -03:00
Paulo Castellano
2c08da7787 feat(tiktok): photo carousel support + UX Content Sharing API compliance
## Photo carousel support

- Adds `ContentType::TikTokPhoto` enum case (max 35 photos, 1:1 aspect,
  supportsImage true, supportsVideo false) and JS mirror in content-type.ts.
- Variant pill picker (Video / Photo carousel) at the top of TikTokSettings,
  mirroring the Instagram pattern. Wired through ScheduleTab to the parent
  editor's existing update:platformContentType emit.
- i18n keys for variant_label / variant.video / variant.photo in en/pt-BR/es.
- Publisher: split buildPostInfo into buildVideoPostInfo (uses `title`,
  TikTok cap 2200 chars) and buildPhotoPostInfo (uses `description`, cap
  4000 chars; omits Duet/Stitch/AIGC since they don't apply). Removed the
  no-longer-needed queryCreatorInfo() call from publishVideo/publishPhotos
  — its only previous consumer (silent privacy_level fallback) is gone.

## UX Content Sharing API compliance

Per TikTok review feedback citing
https://developers.tiktok.com/doc/content-sharing-guidelines#required_ux_implementation_in_your_app

Point 1 — already satisfied (creator_info fetch + nickname display).

Point 2/4 — Music Usage Confirmation declaration is now always visible
in TikTokSettings; text changes between "Music Usage Confirmation" and
"Branded Content Policy and Music Usage Confirmation" based on toggle
state. Previously the entire `<p>` block was conditional on a brand
toggle being selected, hiding the baseline declaration.

Point 2b — privacy_level may not have a default. UI was already correct;
backend hardened: UpdatePostRequest now requires meta.privacy_level for
tiktok platforms when status is publishing/scheduled (via withValidator);
TikTokPublisher::resolveRequiredPrivacyLevel throws TikTokPublishException
(ContentPolicy category) when missing instead of silently falling back to
the creator's preferred level.

Point 2c — interaction settings now condition on content type:
- Photo posts hide Duet/Stitch (they don't apply per TikTok docs).
- Photo posts hide AIGC (also video-only).
- Video posts hide Auto Add Music (photos-only feature).
- Max-duration warning hidden when not a video post.
Source of truth is the user-selected contentType prop, not inferred
from media — ensures the UI reacts immediately to the variant pill.

Point 3a — publish button stays disabled when Disclose toggle is on
without a sub-selection (already the case via tiktokComplianceValid).
The disabled tooltip now uses the verbatim TikTok-required text "You
need to indicate if your content promotes yourself, a third party, or
both." instead of the generic "Some platform settings are incomplete..."
when the only blocker is TikTok disclosure incompleteness.

Point 3b — SELF_ONLY (Only me) privacy option is no longer filtered out
when Branded Content is checked. It is rendered disabled with a hover
tooltip "Branded content visibility cannot be set to private." plus a
persistent amber warning paragraph below the dropdown. When the user
toggles Branded Content while privacy is SELF_ONLY, the privacy clears
and a vue-sonner warning toast surfaces the change.

## Cross-cutting

- New `resources/js/enums/platform.ts` mirrors the PHP Platform enum,
  used in Edit.vue (tiktokComplianceValid + tiktokDisclosureIncomplete)
  and ScheduleTab.vue (all selected*Platforms computeds) to replace
  string literal comparisons against `'tiktok'` / `'facebook'` / etc.
- PostPlatformFactory tiktok() state defaults meta.privacy_level to
  SELF_ONLY so existing test fixtures keep passing under the new
  publisher/FormRequest requirements.

## Tests

- New tests/Unit/Enums/PostPlatform/TikTokPhotoContentTypeTest.php
  covering the new enum case (4 tests).
- TikTokPublisherTest: added "video uses title not description" and
  "throws when meta.privacy_level missing" regression tests; renamed
  two existing tests that depended on the removed silent fallback.
- New tests/Feature/UpdatePostRequestTest.php with 3 tests covering
  the FormRequest's privacy_level enforcement (publish-rejected,
  publish-passes, draft-allowed).

Full Pest suite: 1490 passed, 2 skipped (pre-existing).
2026-05-09 12:47:49 -03:00
Paulo Castellano
148a2f432f feat: AI image generation pipeline and post creation overhaul
Core changes:
- Replace Unsplash slide pipeline with gpt-image-2 via Laravel AI SDK.
  New AiImageClient builds prompts from a Blade template seeded by the
  workspace's ImageStyle enum, content language, brand color (mapped to a
  human-readable name via HexColorName helper) and brand description.
- Drop Template B from TemplateImageGenerator: every slide now renders as
  Template A (full-bleed photo + bottom gradient + white/grey overlay).
  Removes renderTemplateB, roundCorners, blendHex, ensureContrast and the
  closing-slide pipeline.
- StreamPostCreation creates the Post directly and dispatches
  PostCreationReady with post_id; the wizard kills its preview step and
  redirects straight to the post editor on completion. Finalize endpoint
  removed.
- New Workspace.image_style enum field with an 8-option visual picker
  shared by /workspaces/create and /settings/workspace/brand via a single
  BrandForm component (autofill is a prop). 8 sample webp thumbs ship
  under public/images/branding/image-styles/.
- Media items gain optional source ('ai'|'unsplash'|'giphy') and
  source_meta (recipe needed to regenerate AI images later); the gallery
  picker tags Unsplash/Giphy attachments.
- Brand-color autofill: new CssColorFrequencyExtractor parses every
  hex/rgb/hsl value in the homepage CSS, clusters perceptually similar
  shades in CIE LAB (Delta E 76 < 12), filters neutrals and returns the
  most frequent cluster. Solves Tailwind/utility-CSS sites where no
  semantic --primary variable is exposed.
- Credits: gpt-image-2 metered at 15 credits/image (low quality default).
- Layout: AuthSplitLayout right column is sticky/h-svh so the form
  textarea growth no longer stretches the marketing slider.
- i18n cleanup: localized labels follow the no-em-dash convention.
2026-05-08 13:38:30 -03:00
Paulo Castellano
1d116cfbb2 feat: pass and persist post dates through AI creation wizard and template application flows 2026-05-06 17:21:30 -03:00
Paulo Castellano
24a8786cba feat: add multipart media upload endpoint and split URL flow
Adds POST /api/posts/{post}/media for direct file (multipart) upload
and renames the existing URL-based flow to /api/posts/{post}/media/from-url
so the path matches HTTP semantics (POST <resource>/media expects a file
body, not JSON URLs).

The multipart action validates type against the post's enabled platforms
(image rejected on TikTok-only posts), enforces per-type size caps, and
reuses Workspace::addMedia + Post::appendMedia. URL-based attaching is
unchanged behaviorally — only the route name and controller method are
renamed for symmetry. The MCP AttachMediaFromUrlTool was already named
correctly and needs no changes; binary upload via MCP is a host-protocol
limitation that no MCP server (including Postiz) supports.
2026-05-04 18:00:03 -03:00
Paulo Castellano
332513e360 refactor: replace regex/preg_match with sscanf + ends_with in chunked upload
Two regex pieces in StoreChunkedAssetRequest, both replaced by more
fitting tools:

1. Parsing 'Content-Range' header — preg_match with capturing groups
   and $matches[1..3] indexing replaced by sscanf, which is
   purpose-built for parsing structured strings with a known format:

       $parsed = sscanf($header, 'bytes %d-%d/%d') ?: [];
       $start = $parsed[0] ?? null;

   No regex, no $matches array, no manual int casts (sscanf returns ints
   for %d).

2. Validating filename suffix — regex:/\.(jpe?g|png|gif|webp|mp4)$/i
   replaced by Laravel's built-in 'ends_with' rule, with extensions
   derived from the MediaType enum:

       $allowedSuffixes = collect([Image, Video])
           ->flatMap(fn (MediaType $t) => $t->extensions())
           ->map(fn ($ext) => '.'.$ext)
           ->all();

   For the case-insensitive part, normalize the filename to lowercase
   in prepareForValidation so 'IMG_1234.JPG' matches '.jpg' naturally.

Added MediaType::extensions() — single source of truth for filename
suffixes (jpg/jpeg/png/gif/webp + mp4/mov/webm). Mirrors
allowedMimeTypes() for callers that validate by name instead of MIME.
2026-05-04 14:59:57 -03:00
Paulo Castellano
770266d36b refactor: centralize media size limits in config + drop Document type
Three things in one move:

1. Centralize per-type size caps in config/trypost.php under media.max_size_mb.
   The MediaType enum now reads from there:

       MediaType::Image->maxSizeInMb()    // 10  (env: MEDIA_IMAGE_MAX_SIZE_MB)
       MediaType::Video->maxSizeInMb()    // 1024 (env: MEDIA_VIDEO_MAX_SIZE_MB)

   Plus convenience helpers maxSizeInBytes() and maxSizeInKb() so callers
   don't have to multiply themselves. StoreAssetRequest now uses
   MediaType::Video->maxSizeInKb() in its 'max:' rule and mimes derived
   from MediaType::{Image,Video}->allowedMimeTypes(). storeChunked
   validation moved to the new StoreChunkedAssetRequest FormRequest
   (also reads from the enum). MediaAttacher uses
   MediaType::Video->maxSizeInBytes() as the streaming-abort threshold
   and enforces the per-type cap after MIME resolution.

2. Drop MediaType::Document. We never accepted PDFs anywhere — the
   StoreAssetRequest mimes list excluded them, the storeChunked
   extension regex excluded them, MediaAttacher excluded them. The only
   places that referenced Document were:
     - Platform::allowedMediaTypes for LinkedIn/LinkedInPage (declared
       but unreachable)
     - HasMedia::getMediaType fallback when MIME wasn't image/video/*
   Both now cleaned up. HasMedia::getMediaType throws
   InvalidArgumentException for unsupported MIMEs instead of silently
   returning a fake 'document' type. Platform::LinkedIn now matches
   every other social platform: [Image, Video].

3. Add MediaType::fromMime($mime): ?self — replaces the inline mime →
   type loop that MediaAttacher used to roll. Returns null for
   unsupported MIMEs (caller decides how to react).

Tests:
- MediaTypeTest rewritten for the new shape (no Document, config-driven
  sizes, fromMime + size-helper coverage).
- PlatformTest no longer asserts Document on LinkedIn.
- HasMediaTest replaces the 'detects document type' case with one that
  asserts the throw on unsupported MIMEs. The 'add media from path'
  test now uses real PNG bytes from the fixture.
- AssetControllerTest chunked tests use real PNG bytes and assert 422
  (FormRequest unprocessable) for malformed Content-Range headers,
  matching the new validation layer.
2026-05-04 14:54:47 -03:00
Paulo Castellano
ac361349da refactor: extract attach-media validation into a FormRequest
Project convention is one FormRequest per endpoint
(Api/Post/StorePostRequest, UpdatePostRequest, etc.) — the inline
$request->validate() in attachMedia was the only outlier in this
controller. Extracted to Api/Post/AttachMediaRequest with the same
rules:

    'urls'   => ['required', 'array', 'min:1', 'max:10'],
    'urls.*' => ['url:http,https', 'active_url'],

Controller signature is now AttachMediaRequest $request — Laravel
binds + validates before the action runs, same pattern as store/update.
2026-05-04 14:37:49 -03:00