MediaAttacher had grown into a 180-line class with four private
methods doing different things — download, validation, storage,
and post-state mutation. Two of those (lock-and-merge into the
JSON media column, intersection of allowed media types per
platform) are post concerns, not network concerns.
Move them onto the Post model:
Post::allowedMediaTypes(): array<Type>
The intersection of media types accepted by every enabled
platform. Used to be inlined as MediaAttacher::allowedMediaTypesFor.
Post::appendMedia(array $items): void
Lock-then-merge into the post's media[] JSON column so
concurrent writers don't overwrite each other. Used to be
MediaAttacher::mergeIntoPostMedia.
MediaAttacher now reads cleanly:
attachFromUrls — orchestrate (loop URLs, batch the appendMedia)
attachOne — download → validate type/size → handoff
download — Http::sink + progress abort, returns DTO
Drops to ~120 lines with no nested concerns. The streaming progress
abort uses the same throw-RuntimeException pattern, but now
contained in download() instead of leaking into the orchestrator.
Previous attempt dropped both video/quicktime and video/webm citing
narrow platform support. Re-investigated:
- Modern .mov files (iPhone recordings, screen captures) are ISO BMFF
containers — the same format MP4 uses. Social platforms decode them
like MP4 even when PHP's mime_content_type() reports
'video/quicktime'. Postiz quietly accepts MOV via this same trick:
their file-type lib detects the magic bytes as 'video/mp4' and
renames the upload before storage.
- WebM is genuinely incompatible (Matroska + VP8/VP9 stack) — X / IG /
TikTok / FB / Pinterest / Bluesky / Threads all reject it.
So allow MOV, keep WebM out. Rejecting MOV would force every iPhone
user to transcode before uploading — real UX cost for no gain since
the publishers handle it.
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.
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.
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.
The MediaAttacher used to roll its own SSRF guard with DNS resolution
and a static fakeUrlSafety() flag for tests. Validating URLs is a
request-layer concern, not a service-layer one. Laravel ships
'active_url' which does the same DNS resolvability check via
dns_get_record — applying it at the FormRequest / MCP validate() level
catches dead URLs upfront with a proper 422 instead of letting the
download silently fail.
- Replace the inline 'urls.*' => ['url:http,https'] rule with
['url:http,https', 'active_url'] in both Api/PostController::attachMedia
and Mcp/Tools/Post/AttachMediaFromUrlTool.
- Drop isUrlSafe(), fakeUrlSafety(), resetUrlSafety(), $skipUrlSafety
from MediaAttacher. The remaining defenses (Http::sink streaming +
progress abort at MAX_BYTES, allow_redirects: false, MIME allowlist)
cover the operational concerns.
- Restore tests/TestCase to the original setUp — no SSRF bypass needed
anymore because active_url is satisfied by the test hosts.
- Swap synthetic test hosts (cdn.example.com / evil.example.com) for
example.com / example.org. Both are RFC-reserved AND have stable A
records, so active_url accepts them while Http::fake() still
intercepts the actual request.
For SSRF defense beyond 'active_url' (which doesn't block private IPs),
trypost relies on production network egress controls. Open-source
self-hosters who run without a firewall accept the corresponding risk;
that's a deployment concern, not a request validation concern.
The enum already has `allowedMimeTypes()` — it's the single source of
truth for what each media type accepts (jpeg/png/gif/webp for image,
mp4/quicktime/webm for video). MediaAttacher was duplicating the same
arrays as ALLOWED_IMAGE_MIMES / ALLOWED_VIDEO_MIMES constants, which
meant any change to the supported MIMEs needed two edits.
Drop the constants. `resolveType()` now walks
`[MediaType::Image, MediaType::Video]` and asks each type whether the
MIME is on its allow-list. Document type is intentionally excluded so
PDFs aren't accepted via URL fetch.
Note on size: kept MediaAttacher's own MAX_BYTES = 50 MB (separate from
the enum's per-type caps). URL fetches have different operational
constraints than direct uploads (bandwidth, timeout, unbounded user
input) — a smaller cap here is intentional.
Out of scope but worth a follow-up: `StoreAssetRequest` and
`storeChunked` are also drifted (web only accepts video/mp4 today,
the enum + MediaAttacher accept mov/webm too). They should also read
from the enum eventually.
Both PostAiCreateController::createMediaItem and
PostTemplateController::createMediaItem were doing:
$media = new Media([...]);
$media->mediable_type = Workspace::class; // ← FQCN literal
$media->mediable_id = $workspace->id;
$media->save();
Assigning Workspace::class directly bypasses the morphMap configured in
AppServiceProvider, so rows ended up with mediable_type =
'App\\Models\\Workspace' instead of the alias 'workspace'. Other queries
that pivot through the morphMap (e.g. $workspace->media) lose those
records on hosts where the FQCN doesn't match the alias.
Switch both to the relationship form:
$media = $workspace->media()->create([...]);
Laravel fills mediable_type via the morphMap, producing 'workspace'.
This is the same pattern AssetController and the HasMedia trait already
use; these two AI controllers were the only outliers (`grep -rn
'mediable_type =' app/` confirms).
Added tests/Unit/MediaPolymorphTest as a regression — asserts the
created row's mediable_type is the alias and that the relationship
resolves back to the workspace.
The previous split (MediaAttacher + MediaDownloader + UrlSafetyGuard
interface + DnsUrlSafetyGuard impl + provider binding) was over-engineered
for one production implementation. Roll it all back into a single
MediaAttacher class and delegate to the existing `Workspace::addMediaFromPath`
helper for storage + Media row creation (the same path the web upload
flow uses).
What's gone:
- app/Services/Post/MediaDownloader.php
- app/Services/Post/UrlSafetyGuard.php (interface)
- app/Services/Post/DnsUrlSafetyGuard.php (impl)
- tests/Unit/Services/Post/DnsUrlSafetyGuardTest.php
- The provider bind in AppServiceProvider
What's still defended:
- Streaming download via Http::sink with a Guzzle progress callback that
aborts mid-stream once MAX_BYTES is exceeded (no body buffered in PHP).
- Redirects disabled so a 200→302 trick can't pivot to internal hosts.
- IP-literal SSRF guard: rejects loopback / private / link-local /
reserved ranges via FILTER_FLAG_NO_PRIV_RANGE | NO_RES_RANGE. DNS
hostnames are accepted — finer SSRF (DNS rebinding etc.) is left to
network-level egress controls. This is a deliberate simplification:
the prior DNS resolution defense added a class + interface + provider
binding for marginal gain when the realistic attack surface is
hard-coded internal IPs.
- Strict MIME allowlist (no SVG, no PDF, no application/*).
- Lock-then-merge into post.media[] to avoid lost-update races.
Tests bypass the SSRF check via the static `MediaAttacher::fakeUrlSafety()`
called once in tests/TestCase::setUp — same idiom as Mail::fake / Bus::fake.
The previous MediaAttacher had ~5 responsibilities crammed in one class
(URL validation, HTTP fetch, streaming, MIME check, Storage write,
post.media merge) plus an `if ($app->runningUnitTests()) return true`
inside the SSRF guard — production code that knew about test mode.
Split into three:
- UrlSafetyGuard (interface) + DnsUrlSafetyGuard (default impl). The
full SSRF check is now isolated and unit-testable in
tests/Unit/Services/Post/DnsUrlSafetyGuardTest. Tests bind a permissive
guard in tests/TestCase::setUp so feature tests using Http::fake() with
synthetic hosts (cdn.example.com) still work — the runningUnitTests()
check inside production code is gone.
- MediaDownloader: takes a URL, returns a temp file + MIME or null. Uses
Http::sink with a Guzzle progress callback that throws once maxBytes
is exceeded, so we abort mid-stream without buffering the body in PHP
memory (the previous chunk loop was append-to-string which defeated
the whole point of streaming).
- MediaAttacher: pure orchestrator. Calls MediaDownloader, validates the
MIME against the post's enabled platforms, persists via Storage::putFileAs,
appends the Media record under a row lock. Drops from 275 to ~190 lines
with zero responsibility overlap.
The IO contract (`null` on failure, `['path','mime','bytes']` on success)
keeps MediaAttacher free of the temp-file lifecycle on the happy path —
ownership is documented in MediaDownloader's PHPDoc and the orchestrator
unlinks via try/finally in processOne().
Bind the interface in AppServiceProvider so production resolves
DnsUrlSafetyGuard automatically; tests override it.
7 new unit tests cover the SSRF cases (loopback, RFC1918, link-local,
zero/broadcast, IPv6 loopback/ULA/link-local, plus a public-IP positive
test). The 14 existing AttachMedia feature tests stay green.
The same "is this post in the user's current workspace?" check was
duplicated across every Post-related endpoint (5 in Api/PostController
via the ensurePostInCurrentWorkspace helper, 5 in App/PostController
inline). PostPolicy already had a duplicate() method following this
exact pattern — extending it with view/update/delete unifies the
tenancy guard in one place.
- Add view/update/delete to PostPolicy. Each returns
Response::denyAsNotFound() when the post belongs to a different
workspace, so we keep the existing 404 behavior (don't leak
cross-tenant existence) instead of switching to the default 403.
- Update duplicate() to also use denyAsNotFound() for the workspace
mismatch path. The createPost role check still returns bool/403.
- Replace ensurePostInCurrentWorkspace() calls in Api/PostController
with $this->authorize('view'|'update'|'delete', $post). Helper deleted.
- Replace inline workspace_id !== $workspace->id checks in
App/PostController (show/edit/update/destroy/platformMetrics) with
the same authorize calls. The PostPolicy guard now subsumes both
the workspace-tenancy check and the role-permission check that was
previously delegated through Workspace::createPost.
Both ContentTypeMatchesPlatform and ContentTypeMatchesPostPlatform
were using preg_replace to swap the leaf segment of a dotted attribute
path ("platforms.0.content_type" -> "platforms.0.social_account_id").
Cleaner with Str::beforeLast: derive the parent path once, then look
up the sibling under it. Same intent, no regex, and the rule no longer
encodes its own attribute name.
Code-review surfaced two correctness bugs and a security gap that
needed to land before merging.
- UpdatePost::execute disabled every platform when called without
a `platforms` key. PublishPostTool relied on that path, so every
publish-via-MCP queued a job whose handler then found nothing
enabled to publish to. Wrap the platform toggle in
`Arr::has($data, 'platforms')` (matches the existing label_ids
guard a few lines up). Add a regression assertion to
`PostPublishToolTest::publish post immediate dispatches PublishPost
job` that the previously-enabled platform stays enabled.
- StorePostRequest declared rules for only `platforms`,
`scheduled_at`, and `status`. `validated()` then stripped
`content`, `media`, and `label_ids`, so REST `POST /api/posts`
silently created empty drafts. Added rules for content / media /
label_ids (with workspace-scoped `Rule::exists` for labels) and
dropped the unused `status` field — REST callers transition state
via `PUT /posts/{id}`. Removed the dead `platforms.*.content`
rule. Added a feature test that asserts content + media + labels
roundtrip on create, plus a regression that an `is_active=false`
social_account is rejected at validation.
- CreatePost::execute now syncs label_ids itself so REST and MCP
share the behavior. Removed the duplicate sync from CreatePostTool.
- MCP UpdatePostTool didn't scope `platforms.*.id` to the post being
updated, drifting from the REST UpdatePostRequest which adds
`Rule::exists('post_platforms','id')->where('post_id', ...)`. Now
it loads the post first (failing fast with `Post not found.` if
the workspace check rejects), then uses the same Rule::exists.
- MediaAttacher fetched any URL the caller passed, including
loopback / link-local / private targets — classic SSRF pivot.
Now `isPublicHttpUrl` rejects non-http(s) schemes, restricted IP
ranges, and DNS hostnames whose A/AAAA records resolve into those
ranges (covers DNS rebinding). Bypassed under
`app()->runningUnitTests()` so `Http::fake()` keeps working.
Streaming the response body lets us abort early once we exceed
MAX_BYTES instead of buffering the full payload first; redirects
are disabled so a 200→302 trick can't bypass the host check.
- The `media[]` JSON column had a lost-update race in
`attachFromUrls`: read `$post->media`, mutate in PHP, write back.
Two concurrent calls clobbered each other. Now wrapped in a
transaction with `lockForUpdate()`.
- ESLint: `resources/js/actions/**` and `resources/js/routes/**`
are auto-generated by Wayfinder on every build. Their import
order matches PHP scan order, not alphabetical, so import/order
fought eslint-fix forever. Added them to ignores.
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
Mentions in post comments
- @mention autocomplete (workspace members, current user excluded) with
marker syntax @[uuid] persisted, display names rendered via CommentBody
chips; live edit replaces markers with names and converts back on save.
- NotifyMentions action with workspace-scoped membership check, dedupes
same user, only newly-added mentions on update.
- Email + in-app via SendNotification job, respecting per-user
notification_preferences.mentioned_in_comment.
- Heartbeat-based presence (Cache, 60s TTL, 30s ping) so online recipients
get only the in-app notification — no email noise.
- Real-time bell on workspace.{id}.user.{id} private channel
(NotificationCreated event), scoped channel name avoids client-side
filtering and lays out a convention for future workspace channels.
- Mailable localized via lang/{en,es,pt-BR}/mail.php; Maizzle source
template for the email is committed and built into resources/views/mail.
AI generation refactor (Action layer + MCP)
- Extracted Actions/Ai/Generate{Image,Video} with QuotaExhaustedException
so agent tools and MCP tools share a single domain entry point.
- Mcp/Tools/Ai/Generate{Image,Video}Tool registered in TryPostServer; both
return MediaResource payloads.
- Orientation::imageApiSize maps non-OpenAI ratios to 1:1/2:3/3:2.
- config/ai.php is now the single source of truth driven by env, removing
the trypost.ai shim. Default text/image providers flipped to OpenAI.
Settings/UX
- /settings/workspace split into shadcn Tabs (Workspace / Brand / Users)
with three components.
- /assets and the in-editor MediaPicker open the ImagePreviewDialog
lightbox on image click while preserving action button behaviour.
- Comments tab landed via ?tab=comments&comment=<id> from notification
click (scroll-to + temporary highlight).
- Mention autocomplete popover flips above when near the viewport bottom.
- Real social platform PNGs replace Tabler brand glyphs in schedule
pills and post list, with hover tooltip carrying display_name + handle.
Bug fixes
- AcceptInvite: controller now passes workspace + role payload that the
Vue page expects; login/register CTAs preselect the invite email.
- WorkspaceInvite mailable: stopped referencing nonexistent
$invite->workspace and $invite->role; column added to the migration,
Invite model casts role to WorkspaceRole, CreateInvite persists it.
- PostCommentCreated: added broadcastAs so .PostCommentCreated actually
matches the Echo listener; payload now includes mentioned_users so
receivers render the chip correctly without a refetch.
- Preview components for X/Pinterest/Threads/Bluesky/LinkedIn/Mastodon/
TikTok/YouTube switched from item.type === 'image' to
!isVideoMedia(item) so media without a persisted type still renders.
- UpdatePostRequest now accepts media.*.{type,mime_type,size,...} so the
posts.media JSON keeps the metadata that the previews need.
- Removed throttle:6,1 from social connect routes (was 429ing legitimate
OAuth retries).
- Used MediaType enum cases instead of literal 'image'/'video' strings
when creating media rows.
Tests
- MentionParser unit tests, NotifyMentions feature tests including
online/offline channel selection and preference gating, MCP AI tool
happy paths, MentionedInComment mailable rendering, AcceptInvite +
search-members + index mentioned_users path. 1229 passing.
- gallery: extract /assets tabs (uploads, Unsplash, Giphy) into shared
GalleryBrowser used by both /assets and a new MediaPickerDialog inside the
post editor; add JSON search endpoint for workspace assets with tests
- emoji: replace broken emoji-picker-element web component with a custom
EmojiPicker (full Unicode set, search, categories, recently-used,
light/dark, i18n)
- preview tab: platform selector pills, variant tabs (data-driven from
content_types map) so the user can switch Feed/Reel/Story etc. and have
it autosave through the same handler ScheduleTab uses
- platform logos: shared usePlatformLogo composable (logo + label + content
types); replaces inline maps across 5 components, fixes
instagram-facebook falling back to default.png
- tooltips: hover details (display_name · @username + platform label) on
platform avatars across editor, posts list and calendar
- settings cards: show ` · @username` in the title bar so multiple accounts
on the same network are distinguishable
- routes: drop the throttle:6,1 group middleware on social connect routes
(was 429ing legitimate OAuth retries) and rely on the default limiter
When config('trypost.ai.text_provider') points to a provider whose
API key is populated (services.gemini.api_key or
services.openai.api_key), the autofill action pipes the homepage
markdown through a BrandAnalyzer agent with structured output to
produce higher-quality values for:
- brand_description — 2-3 sentences summarizing the company based on
the actual page content, not the raw meta description (which is
often generic SEO boilerplate)
- brand_tone — classified into one of our seven enum values from the
writing style on the page
- content_language — detected from the actual content (more reliable
than <html lang> which is often wrong)
- brand_voice_notes — concrete writing guidelines inferred from the
site's style, written in the detected language
When the LLM provider is NOT configured (open-source self-hosted
deploys without API keys), the action keeps the existing
deterministic meta-tag-only flow — no crash, no noise, no LLM cost.
When the LLM fails mid-request, we log a warning and fall back to
the meta-tag values so the user still gets something useful.
Stack additions:
- league/html-to-markdown ^5.1 converts the main body to clean
markdown for the LLM input (truncated to 4000 chars).
- BrandAnalyzer agent (Agent + HasStructuredOutput) with schema
enums matching our allowed tones and languages.
- resources/views/prompts/brand_analyzer.blade.php holds the
instructions, including explicit enum lists and examples of good
voice_notes.
Frontend: Brand.vue now also fills brand_tone and brand_voice_notes
from the response when present.
Tests (+3): LLM-configured happy path, no-credentials fallback
(asserts BrandAnalyzer is never prompted via preventStrayPrompts),
and LLM-exception fallback (meta tags win, brand_tone stays null).
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
After users pick their persona (role), they now land on a new Brand
step that collects the same fields available in Settings → Workspace
→ Brand: website, description, tone, voice notes, and content
language. When they continue, every AI-generated post for this
workspace already has sensible defaults — before the user's first
post is ever drafted.
Flow:
Role (persona) → Brand (new) → Connections → Subscription → Completed.
A 'Skip for now' button on the brand step advances to Connections
without touching the workspace (defaults stay at their seed values).
Backend:
- Setup enum gets a new Brand case slotted between Role and
Connections with matching stepNumber updates.
- OnboardingController::brand() renders the form pre-filled from the
current workspace. storeBrand() validates via a new
StoreBrandRequest form request and writes the fields onto the
workspace, then advances setup. skipBrand() just advances.
- storeRole() redirects to brand instead of account. enforceStep()
knows how to redirect users whose setup is Brand.
- Three new routes: GET /onboarding/brand, POST /onboarding/brand,
POST /onboarding/brand/skip.
Frontend:
- New Brand.vue page mirrors the Settings brand form but inside the
onboarding AuthLayout. Tone + language sit side by side, both
selects take full width. Translations added to en, pt-BR, and es.
- Wayfinder regenerated so the page can import storeBrand / skipBrand.
Tests:
- Renamed 'redirects to step2' → 'redirects to brand step' and
assert new setup.
- Added six new tests covering brand step auth, redirects, render,
successful store, validation of tone and content_language, and
skip.
- Updated UserSetupTest for the new enum case + reshuffled step
numbers.
Users can now pick the language AI uses for captions, hashtags,
descriptions, AND on-image / on-screen text — independent of the
app UI locale. A user with pt-BR UI who writes for an English
audience gets English content, and vice versa.
- New content_language column on workspaces (added to the existing
create_workspaces_table migration since we're pre-production),
defaulting to 'en'. Fillable on the model.
- Dropdown in Settings → Workspace → Brand, side-by-side with Tone.
Options: English, Português (Brasil), Español. Short helper copy
explains what the setting controls.
- Validation: sometimes|string|in:en,pt-BR,es — optional on update
so existing callers that don't post the field keep the current
value (DB default handles fresh workspaces).
- System prompt now uses $content_language (sourced from the
workspace) instead of app()->getLocale(). The rule was also
strengthened: the AI must always write in this language regardless
of what language the user types instructions in, and must instruct
image/video tools to render on-media text in this language too.
- Image and video Blade prompts received a new line forcing any text
rendered inside the generated media to use $content_language.
- GenerateImage tool and VideoGenerationService pass the workspace's
content_language into their respective prompt templates.
Previously the caption would be in Portuguese (since it followed the
user's input language) but the image could render with English text
because there was no explicit constraint. Now the whole pipeline is
aligned to one per-workspace language.