Two adjustments from the PR review.
Stripe placeholders (STRIPE_KEY, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET,
and the eight price IDs) only matter when SELF_HOSTED=false. The
Docker stack defaults to SELF_HOSTED=true, so keeping the block in
.env.docker.example just adds noise for first-time contributors.
upload_max_filesize and post_max_size go from 100M to 1G in both
php.dev.ini and php.prod.ini to accept full-length video uploads.
Bumping nginx client_max_body_size to match — otherwise the proxy
would cut the request off before PHP ever sees it.
Adds a "Run with Docker" section between "Get Started" and
"Contributing" covering: single-command boot, common commands,
compose.override.yaml.example, troubleshooting (port conflicts,
UID/GID on Linux, first-boot expectations), and the production image
build hint for self-hosters.
The previous compose.yaml referenced ./vendor/laravel/sail/runtimes/8.5
which was never wired up (sail isn't installed and the runtime path
doesn't exist), so docker compose up failed out of the box. It also
lacked Reverb, queue workers, and the scheduler.
The new compose.yaml:
- Builds the dev target of docker/Dockerfile so a single
`docker compose up --build` brings up the entire stack with no host
PHP or Node prerequisites.
- Bind-mounts the project root for hot-reload, with named volumes for
vendor/ and node_modules/ so image-baked deps survive the bind.
- Pins Postgres to 16-alpine (matches CI) and Redis to 7-alpine.
- Healthchecks gate `app` startup until pgsql and redis are ready.
- Sets DB_HOST=pgsql, DB_USERNAME=postgres, REDIS_HOST=redis as
process env so .env.testing's pre-existing CI defaults
(DB_HOST=127.0.0.1, DB_USERNAME=root) don't break tests run from
inside the container.
compose.override.yaml.example documents per-developer customizations
(UID/GID alignment on Linux, port conflicts, Xdebug, Selenium) without
forcing a specific workflow. .env.testing.local is added to .gitignore
as a safety net for any future per-developer testing override.
Introduces a self-contained Docker build under docker/ that boots the
full Laravel + Vite + Reverb + Horizon + scheduler stack inside a
single container.
The Dockerfile exposes two targets sharing a common system-base layer
(PHP 8.4-FPM Alpine + Postgres/Redis/intl/sockets/redis extensions):
- dev: bind-mount source at runtime, runs Vite via supervisord, hot
reloads PHP via opcache.validate_timestamps=1, UID/GID build args
align container writes with the host user.
- production: ships the prebuilt application — composer --no-dev,
npm run build + build:ssr, wayfinder TS pre-generated, OpCache
hardened, fixed UID 1000.
Sidecar configs (nginx, php.{dev,prod}.ini, supervisord.{dev,prod}.conf,
entrypoint, postgres-init for the test DB) live next to the Dockerfile
so the build context is self-describing. The entrypoint is idempotent
and handles APP_KEY generation, migrations, storage:link, Passport
keys, Wayfinder regen, and dependency reinstall on every boot.
Five small fixes scoped to the publishReel method:
1. Replace generic \Exception on media download failure with a typed
FacebookPublishException(ServerError). The generic exception was
landing in the \Throwable catch in PublishToSocialPlatform with
category 'unknown', defeating the whole point of the social
exception hierarchy.
2. Replace handleApiError($startResponse) with a direct
FacebookPublishException throw when video_id/upload_url are missing.
The previous code passed a successful HTTP response into a method
built for error responses — fromApiResponse would fall into the
default arm and surface 'An unknown Facebook error occurred.'
ironically reintroducing the same bad UX we just spent the day
fixing.
3. Drop the four redundant Log::error calls before handleApiError.
FacebookPublishException::fromApiResponse already pulls the FB
error code/subcode/message into platformErrorCode + userMessage,
and the downstream catch in PublishToSocialPlatform::handle logs
the exception anyway (Nightwatch picks that up). Same pattern as
the X cleanup in PR #29.
4. Stream the upload body via fopen() resource instead of
file_get_contents(). Eliminates loading the whole video into memory
for large reels.
5. Replace \@unlink with unlink + Log::warning. Surfaces temp-file
cleanup failures instead of silently leaking files.
Tests:
- Strengthened the missing-upload_url test to assert the exception
message and class.
- Added a typed-exception test for the media-download failure path
(would have caught the regression where we used a generic
\Exception).
- Full suite green: 1505 passed, 2 skipped, 0 failed.
Previous attempt at the hosted-file flow (`file_url` in JSON body) hit
the rupload.facebook.com validator with HTTP 400:
'HeaderValuePredicate: Header Offset not convertable to unsigned long'
Facebook's rupload endpoint requires the `Offset` and `file_size` headers
regardless of whether the upload is local or hosted-file. The docs
describe the hosted-file path without them, but in practice rupload
rejects requests that lack them.
Switching to the well-documented local-file flow:
- Download the media to a temp file via Http sink (already a pattern
used in XPublisher for media downloads).
- POST raw bytes to upload_url with three headers:
- Authorization: OAuth {token}
- Offset: 0
- file_size: {actual bytes}
- Use mime_type from the media item as the Content-Type.
- Always cleanup the temp file in a finally block.
Tests updated to fake the media-download GET (returning bytes that the
publisher then re-uploads) and to assert on the Offset/file_size/
Authorization headers being present and correct. The existing
"cleans up temp files after reel upload" test now actually exercises
its assertion since this code path creates temp files again.
Full suite: 1504 passed, 2 skipped, 0 failed.
Production was failing every Facebook reel publish with the cryptic
'Video Upload Is Missing' / error_subcode 1363130. Root cause: our
'transfer' phase was making up its own API contract that doesn't exist.
The Reels publishing API (per Meta docs) is a 3-step flow:
1. start → POST /{page_id}/video_reels {upload_phase=start}
returns {video_id, upload_url}
2. transfer → POST {upload_url} on rupload.facebook.com
Header: Authorization: OAuth {token}
Body (JSON): {"file_url": "https://..."}
3. finish → POST /{page_id}/video_reels {upload_phase=finish, ...}
Our code was doing:
2 (broken) → POST /{video_id} on graph endpoint
Body: {video_file_chunk: '<some URL>', access_token: ...}
`video_file_chunk` is not a real parameter; the graph endpoint accepted
the request (returning 200) but nothing was actually uploaded, so the
finish phase reported 'Video was not uploaded'.
Changes:
- Capture upload_url from the start response and use it for transfer.
- POST to that upload_url with file_url in JSON body and
'Authorization: OAuth ...' header (the format docs.facebook.com
documents for the hosted-file flow).
- Bail with handleApiError if the start response is missing video_id
or upload_url so we don't silently no-op like before.
Tests:
- Existing 'can publish reel' test updated to include upload_url in the
start mock and assert the transfer call actually POSTs to rupload
with file_url + OAuth header (would have caught this bug before
shipping).
- New 'fails reel publish when start does not return upload_url' test
for the safety bail-out.
- Pre-existing 'cleans up temp files after reel upload' test updated
to match the new mock pattern.
Full suite green: 1504 passed, 2 skipped, 0 failed.
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).
Three orthogonal fixes that together close the gap where social tokens
were silently aging out without ever being refreshed, then dying at the
provider when the refresh_token also got revoked.
The original failure mode: a user's X token expired because the hourly
proactive-refresh cron's smart `verify()` skip-logic kept saying 'token
still works, no need to refresh', and once the token actually expired,
the cron's WHERE clause excluded it from future runs. By the time anyone
noticed, the refresh_token at X was also gone.
(C) ConnectionVerifier: rename private `refreshTokenIfNeeded` →
public `refreshToken`. Callers that want the smart 'try
access_token first' behavior keep using `verify()`. Callers that
want a proactive refresh (the cron) call `refreshToken` directly.
(B) RefreshExpiringTokens command: drop the
`where('token_expires_at', '>', now())` filter. Already-expired
tokens now get a last-chance refresh attempt before the
refresh_token also dies at the provider. Status filter
(`Connected`) still excludes accounts already marked TokenExpired.
(D) RefreshSocialToken job: switch from `verify()` to
`refreshToken()`, and on `TokenExpiredException` call
`markAsTokenExpired` so the user is notified immediately. The lock
+ transition detection in markAsTokenExpired prevents notification
spam if subsequent cron passes also fail.
Tests:
- 3 new tests for RefreshSocialToken (calls refreshToken not verify,
marks TokenExpired on TokenExpiredException, logs warning on other
errors)
- Updated RefreshExpiringTokens test to assert already-expired tokens
are now dispatched (was previously asserted as 'should NOT')
Fills gaps surfaced in the code review:
- 3 new `markAsDisconnected` tests (mirroring the existing TokenExpired
trio). Previously the method had zero coverage despite this PR
changing its lock key.
- 1 test confirming `markAsTokenExpired($msg, notify: false)` skips
notification dispatch (used by VerifyWorkspaceConnections batch path).
- 1 test confirming `disconnected_at` is preserved when already set
(the `?? now()` branch).
- 2 end-to-end tests (one per method) that DO NOT fake the queue, so
`SendNotification::handle()` runs synchronously. Asserts the
Notification row is actually created in the DB with the correct
i18n-substituted title/body, that NotificationCreated event is
dispatched, and that AccountDisconnected mail is queued. Catches
bugs where the i18n placeholder keys (`:platform`, `:account`)
silently fail to substitute.
Two follow-up fixes from the code review:
1. **Unified lock key for markAsDisconnected / markAsTokenExpired.**
Both methods now use `social_account_status:{id}` instead of
different keys. Prevents the race where `markAsDisconnected` and
`markAsTokenExpired` could run concurrently on the same account
(publish-time vs verify-batch-time), causing overlapping updates and
duplicate notifications.
2. **i18n for notification title/body in markAsTokenExpired and
markAsDisconnected.** Strings were previously hardcoded in English.
Added `notifications.account_disconnected.{title,body}` and
`notifications.account_token_expired.{title,body}` in en, pt-BR, es.
Follows the project convention (e.g. `Mail/PostPublished`) of
concatenating the `@` prefix in PHP before passing the username to
the translation placeholder, instead of putting `@:account` in the
lang file.
These tests use Http::fake, model factories, and DB — by Laravel/Pest
convention that's a feature test, not a unit test. Moving them to the
Feature suite to match the convention.
No code changes. Tests still pass: 1493 passed, 2 skipped, 0 failed.
Extends the X-only fix to LinkedIn, LinkedInPage, Pinterest, Threads, and
TikTok. Each publisher's `refreshToken` now throws `TokenExpiredException`
directly when the OAuth refresh endpoint rejects the refresh_token, instead
of routing through `handleApiError` -> `<Platform>PublishException::fromApiResponse`.
Why: `fromApiResponse` is designed for the publish API response shape and
only converts certain status codes to `TokenExpiredException` (e.g., 401).
OAuth refresh failures typically return 400 with an `error`/
`error_description` (or platform variant) body and were falling into the
generic 'Unknown' bucket, masking the real cause and skipping the proper
`markAsTokenExpired` + user-notification flow.
Dropped the redundant `Log::error` before each `throw`: the downstream
catch in `PublishToSocialPlatform::handle` already logs the exception
(captured by Nightwatch), and the rich context now lives on the exception
itself (`message` = provider's `error_description` / `error.message`,
`platformErrorCode` = HTTP status).
Tests: one regression-style test per publisher confirming refresh rejection
becomes `TokenExpiredException` instead of a generic publish exception.
Three related fixes for the failure mode where a scheduled post errors out
as 'An unknown X error occurred.' when a social account's refresh_token
was already invalidated by the provider:
1. **PublishToSocialPlatform**: fail-fast when account status is
`TokenExpired`. Previously the job tried to publish, the publisher
internally tried to refresh, the provider rejected the rotated
refresh_token, and the failure surfaced as a generic 'unknown' error
instead of a clear 'reconnect your account' signal.
2. **XPublisher::refreshToken**: when the OAuth endpoint rejects the
refresh_token (typically because it was rotated/revoked at X), log the
raw response and throw `TokenExpiredException` instead of falling
through to `XPublishException::fromApiResponse` which expects the
tweet-API response shape (`type`/`title`/`detail`) and treats
OAuth-style responses (`error`/`error_description`) as 'Unknown'.
3. **SocialAccount::markAsTokenExpired**: dispatch an in-app + email
notification (`Type::AccountDisconnected`) when an account
transitions from `Connected` → `TokenExpired`, mirroring the
existing pattern in `markAsDisconnected`. Wrapped in a lock to
prevent duplicate notifications on concurrent transitions. Accepts an
optional `notify: false` so the batch verifier
(`VerifyWorkspaceConnections`) can suppress per-account
notifications and rely on its summary email.
`$account->stripe()` can throw `\Stripe\Exception\InvalidArgumentException`
(descends from PHP's `InvalidArgumentException`, not `ApiErrorException`)
when the Stripe key is missing/malformed. Since the conversion data is
purely for tracking, any failure must degrade silently — not break the
post-payment success page.
Wire `value`, `currency`, and `transaction_id` from Stripe Checkout Session
into the `purchase` dataLayer event so GTM can fire Google Ads Conversion
Tracking with accurate per-plan revenue and deduped transaction IDs.
- `BillingController::checkout` adds `{CHECKOUT_SESSION_ID}` to success_url
- `BillingController::processing` retrieves session lazily (closure prop,
so polling partial reloads don't re-hit Stripe) and exposes
`conversion` with `value`/`currency`/`transaction_id`
- `Processing.vue` forwards `conversion` to `trackPurchase`
- `useTracking.trackPurchase` pushes `conversion_value`,
`conversion_currency`, `conversion_transaction_id` to dataLayer +
PostHog
The inline "Generate post with AI" dialog never showed any output, even
after the queue job finished. Three independent issues stacked:
1. Event name mismatch in useAiStream. Listeners were CamelCased
(.TextDelta, .StreamEnd, .Error) but laravel/ai broadcasts events
with snake_case names via StreamEvent::type() (text_delta, stream_end,
error). Echo never matched, the listener never fired, status stuck on
'streaming' forever. Renamed to snake_case.
2. The streamer agent shares the structured-output prompt template with
PostContentGenerator, so the stream emits a JSON object like
{"content":"...","image_title":"...","image_keywords":[...]}, not
plain caption text. Added a previewText computed in AiGenerateDialog
that JSON.parse's the accumulated text and grabs .content. Mid-stream
the parse fails, so the preview stays empty (showing the existing
'...' placeholder) until stream_end produces a complete JSON.
3. The Generate button used v-if="status === 'idle'" — it disappeared
the instant generation started, leaving the user with no feedback.
Swapped to v-if="status === 'idle' || status === 'streaming'" and
used the Button component's built-in :loading prop so it stays
visible with a spinner inside while the agent runs.
Two more pieces in app/Ai/ that were registered but never reached by any
production caller, same audit as the PlatformRules cleanup.
DebugGeminiRequest middleware: zero references — not registered in the
laravel/ai middleware pipeline, no agent imports it. Pure dev-time
scaffolding that got left behind.
ExtendedGeminiProvider: registered via Ai::extend('gemini', ...) so the
class IS resolvable when AI_DEFAULT=gemini, but its only override —
defaultImageOptions — is unreachable in practice:
- All text agents (PostContentGenerator, PostContentHumanizer,
PostContentReviewer, PostContentStreamer, BrandAnalyzer) only call
text generation, never defaultImageOptions.
- Image generation (AiImageClient) hardcodes OpenAI's `gpt-image-2`
model, so the Gemini provider isn't on the image path either.
Removed:
- app/Ai/Middleware/DebugGeminiRequest.php
- app/Ai/Providers/ExtendedGeminiProvider.php
- tests/Unit/Ai/Providers/ExtendedGeminiProviderTest.php
- Ai::extend('gemini', ...) wiring + configureAi() method and its four
imports (ExtendedGeminiProvider, Ai, GeminiGateway, Dispatcher) in
AppServiceProvider.
Left over from the PostEditorHeader extraction in #26 — the header now
derives its own isPublished from the post prop, and Edit.vue itself
doesn't use it anywhere. ESLint caught it on CI.
The whole app/Ai/PlatformRules/ system was registered in AppServiceProvider
but never read in production code. Confirmed via grep across app/, resources/,
config/, database/, tests/ — only consumers were:
- AppServiceProvider::configurePlatformRules() registering everything in the
Registry (which nothing then called)
- tests/Feature/Ai/PlatformRules/RegistryTest.php asserting the registrations
it had just performed
The actual AI text agent (PostContentGenerator) reads the per-platform caps
straight from the Platform enum (maxContentLength / recommendedAiContentLength)
and feeds them into the prompt template — bypassing this layer entirely.
Removed:
- app/Ai/PlatformRules/ (12 files: Contract, Registry, 10 Rule classes)
- tests/Feature/Ai/PlatformRules/RegistryTest.php
- 11 use imports + boot() call + configurePlatformRules() method in
AppServiceProvider
Scheduled posts were silently editable: auto-save fired on every keystroke
with status='scheduled', and the backend either silently failed (per-platform
content-length, our new rule) or accepted invalid changes that blew up at
publish time. Editing a queued post is also a race with the publisher job.
UX change: scheduled state now reads-only the editor and exposes one
explicit escape — Unschedule to edit. The post drops to draft, user edits,
re-schedules. Matches Buffer/Hootsuite/Later/Postiz.
Implementation:
- New isLocked computed (= isReadOnly || isScheduled) gates save(),
triggerAutosave(), debouncedSave(), togglePlatform(). submit() stays on
isReadOnly so unschedulePost('draft') can still transition out.
- Editor body wraps in pointer-events-none + opacity-60 when isScheduled,
visually dimming inputs without per-input :disabled plumbing.
- Header swaps to a violet "This post is scheduled / Unschedule to edit"
banner replacing the entire normal action bar. Delete is hidden in
this state — only escape is Unschedule, then act on the draft.
Refactor: extracted the entire header into PostEditorHeader.vue (matches
the PostEditorComposer / PostEditorSidebar family). Single <header> root
with v-if/v-else branches by status. ~85 lines out of Edit.vue.
Cleanup: dropped orphan posts.edit.scheduled_for translation key from
en/pt-BR/es — replaced by the new scheduled_overlay_* keys.
- 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.
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.
Two related fixes that together eliminate the 'Loading your TikTok
account settings…' flicker users were seeing on every keystroke /
variant click in the post editor:
1. PostController::edit no longer wraps tiktokCreatorInfos in
Inertia::defer. The map is computed during the initial render and
shipped as a regular prop. Without defer, the prop never resets to
null between Inertia visits, so the loading line never reappears.
2. TikTokCreatorInfo::fetch is now wrapped in a 5-minute Cache::remember
keyed by social_account_id. Autosaves (which round-trip through
PostController::update → back() → edit() again) used to issue a
fresh TikTok API call for every connected account on every save —
now the cache short-circuits them. Creator info changes very rarely
(only when the user updates privacy settings on TikTok itself), so
five minutes of staleness is acceptable; the worst case is a
slightly out-of-date privacy-options list that corrects on next
page load.
Frontend cleanup: dropped the creatorInfoLoading prop, the inline
loading <p>, and the now-orphaned posts.form.tiktok.creator_info_loading
i18n key in en/pt-BR/es. ScheduleTab no longer passes the prop.
Pinterest has the same UX bug: default content_type is pinterest_pin
(image-only, 1 file). If the user attaches a video, the tile would
silently disable; if they attach 2+ images, also disabled — even though
pinterest_video_pin and pinterest_carousel exist as alternatives.
List all three Pinterest variants so the tile's lenient compliance
check considers each, and togglePlatform snaps to whichever fits the
attached media on selection.
Instagram, Facebook, and LinkedIn intentionally stay out: their defaults
(feed/post) accept both images and videos, so there's no silent block.
Including them would mask real per-variant errors (e.g. Reel + image
incompatibility) — that's a user-visible mistake we want surfaced, not
hidden at the tile level. The map is documented to that effect.
Replaces two TikTok-specific code paths (one in platformIssues, one in
togglePlatform) with a single generic helper. PLATFORM_VARIANTS maps
each platform that exposes multiple content types via a variant picker
to the list of those content types; firstCompatibleVariant returns the
first variant that fits the current media (or null when none fits).
Both consumers — the tile-level compliance check and the on-select
content_type snap — now share the same helper. Adding another platform
later (Instagram already has multiple variants if its default ever
changes) only requires extending the map, no logic changes.
Net effect: -15 lines, no TikTok-specific branching in logic, only data.
Reverts the previous reactive watcher approach. Replaces it with two
small, declarative changes:
1. platformIssues for TikTok now checks whether ANY variant
(tiktok_video / tiktok_photo) accepts the current media. The tile
only stays blocked when neither variant fits (e.g. a GIF, mixed
media). This matches the platform's real capability — TikTok does
accept both videos and photos — and stops disabling the tile based
on the post-platform's stale default content_type.
2. togglePlatform, when SELECTING TikTok, switches the post-platform's
content_type to the variant that matches the first attached media
(tiktok_video for video, tiktok_photo for image). Runs only on
selection, not as a watcher — no implicit reactivity, no side
effects on subsequent media changes. The variant picker inside
TikTokSettings remains the user's manual override.
Reproducer fixed: new post → attach image → TikTok tile is now
clickable instead of greyed out with 'Only videos are allowed for
this format'. After clicking, content_type is tiktok_photo.
The TikTok platform tile was getting silently disabled when the user
attached images before selecting TikTok: a fresh post defaults TikTok
to tiktok_video, the compliance check rejects images for video posts,
and platformIssues marks the tile as un-clickable.
Watch the media ref. If the first attached item is an image and the
TikTok post-platform's content_type is still tiktok_video (or vice
versa for video media on tiktok_photo), flip the content_type to match.
The variant picker inside TikTokSettings can still override afterwards;
this only covers the gap between adding media and opening the panel.
Reproducer: new post → attach image → click TikTok tile → previously
disabled with 'Only videos are allowed for this format', now enabled
and content_type lands on tiktok_photo.
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.
- 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.
## 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).
TikTok's photo endpoint caps the 'title' field at 90 UTF-16 runes but
allows 'description' up to 4000. Today the publisher always sends the
caption in 'title', which would silently fail for any photo caption
longer than 90 chars. Split buildPostInfo into buildVideoPostInfo
(uses title) and buildPhotoPostInfo (uses description, omits Duet/
Stitch/AIGC since they don't apply to photos).
Adds the enum case TikTokPhoto = 'tiktok_photo' with maxMediaCount 35,
supportsImage true, supportsVideo false, 1:1 aspect ratio. The publisher
service already calls /post/publish/content/init/ correctly — this
enum case is the missing data layer that lets validation, UI, and
defaults treat photo posts as a first-class content type.
Replaces the vague "intro → development → conclusion" guidance in the
generator prompt with an explicit per-slide arc: open with a specific
real urgent problem, develop the idea while creating swipe motivation,
prove with concrete evidence (result/before-after/BTS/learning), and
close with a single specific next action. Includes a slide_count to
role-distribution table (2 through 6+ slides).
Adds a `role` enum field to each slide in the carousel JSON schema
(hook, development, proof, cta) so the structure is enforced via SDK
not just prompt text. Field is additive — handleCarousel still reads
only title/body/image_keywords, so consumers are unaffected.