Backend exposes account and plan as proper Inertia resources so any
page can read them off shared props:
- AuthAccountResource — id, name, created_at
- AuthPlanResource — id, slug, name, interval (derived from the active
Cashier subscription's stripe_price)
Both wired into HandleInertiaRequests::share so `auth.account` and
`auth.plan` are available everywhere.
Frontend pushes app + identity context to GTM's dataLayer on every
page load via initializeDataLayer (resources/js/datalayer.ts), and
useTracking gets begin_checkout/purchase wired in the billing flow.
The pushes are no-ops without GTM configured (the array still exists
in memory, nothing reads it). Self-hosted instances without GTM_ID
incur zero error noise. The GTM partials in app.blade.php were
already conditional on `config('services.gtm.id')`.
Processing.vue polls `auth` alongside `subscriptionActive` so
auth.plan.interval is fresh once the Stripe webhook creates the local
Subscription row (it doesn't exist yet at the initial render). The
watch fires only on the false → true transition; an onMounted
fallback redirects users that land on the page with an already-active
subscription without re-firing trackPurchase.
The Connect button on /settings/authentication pointed at the
auth.{provider}.redirect routes that live behind `guest` middleware,
so authenticated users were bounced to /app/home before reaching
Socialite. The OAuth callback also needed to handle two flows
(signup/login vs link to current user) but had no branch for the
second case — meaning a different-email GitHub account would have
been registered as a new user, logging the original session out.
Splits the flows by intent:
- New `app.authentication.connect-provider` route in the auth group,
handled by the settings controller (where it sits next to
disconnect-provider). Replaces the OAuth signup link as the
Connect button's target.
- Auth callbacks moved out of the guest group (still one URL per
provider, since OAuth apps only register one) and gain a single
Auth::check() branch that calls connectToCurrentUser().
- connectToCurrentUser() rejects if the provider id already belongs
to a different user; otherwise sets it on the current user and
redirects back to settings with a flash message.
Persists marketing attribution and registration metadata for new users
across the three signup paths (email, Google, GitHub):
- 5 utm_* columns + registration_ip on the users table
- PreservesUtmParameters trait stores incoming utm_* query params on
the register/redirect GET, retrieves them on the POST/callback —
surviving the OAuth round-trip via session
- request()->ip() captured at the controller layer
Adds GitHub as a second OAuth provider:
- GitHubController mirroring the Google one (now renamed from
SocialLoginController for symmetry)
- Settings → Authentication can connect/disconnect GitHub like Google
- Single SocialLogin.vue component replaces the per-provider buttons
on Login/Register, rendering each enabled provider plus a single
"or continue with" divider
UserFactory gains defaults for the new nullable columns so model
strict-mode access in tests doesn't trip.
- Add `php artisan passport:keys --force` step to tests workflow
so the OAuth2 server keys exist before tests run. Without them
every API test failed with `LogicException: Invalid key supplied`
from `vendor/league/oauth2-server/src/CryptKey.php`.
- Clear 4 pre-existing unused-variable lint errors that were
blocking this PR's CI (auth/GoogleAuthButton, billing/Subscribe,
posts/editor/CommentsTab, posts/editor/TikTokSettings).
- `eslint --fix` also reordered imports alphabetically in 11 other
files — pure cosmetic.
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
The Brand.vue autofill was hitting the endpoint with a raw fetch()
call including manual CSRF header wiring, content-type boilerplate,
and error parsing. Inertia v3 ships useHttp() for standalone HTTP
requests that handles all of that natively — matching the convention
already used elsewhere in the app (YouTubeAnalytics, XAnalytics,
InstagramAnalytics).
Typed the payload and response shape so the composable gives us
type-safe access to the fields we care about. Error handling now
reads from the Inertia exception's response.data.message.
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).
Reka UI's SelectValue extracts the displayed text from the slot of
the currently-selected SelectItem. When that slot is a reactive
{{ $t('onboarding.brand.tone_professional') }} expression, the
library reads the DOM content before Vue applies the translation,
leaving the raw key visible in the trigger even after the list has
rendered correctly.
Bypass the lookup by rendering the translated label directly inside
<SelectValue> via a computed that maps the current v-model value to
its localized string. The SelectItem slots keep their translations
for the dropdown menu itself, but the closed-state trigger no longer
depends on DOM introspection.
Applied to both the onboarding Brand form and Settings → Workspace.
trans() is the laravel-vue-i18n helper that must be imported into
<script setup> for use in TS logic (like toast messages). Inside
<template>, the plugin registers a global $t() that should be used
instead — imports aren't needed and it matches Vue I18n conventions.
Converted all :placeholder / :title / :description / :action bindings
in Brand.vue and Workspace.vue to $t(). Also added explicit
:placeholder to the Brand and Workspace Selects so if the initial
v-model value hasn't resolved to a matching SelectItem yet, the user
sees the field label instead of the raw translation key.
Dropped the now-unused trans import from Workspace.vue.
The onboarding Brand page was reusing settings.brand.* keys, which
coupled two unrelated pages: a rename in Settings would silently break
onboarding, and the two pages might want to diverge copy later (e.g.
more welcoming language during first-run vs terse settings labels).
Duplicated the relevant keys (field labels, placeholders, tone
options, content language copy) into onboarding.brand.* across
en/pt-BR/es. The Settings workspace page keeps using
settings.brand.* — they're the source of truth for that page now.
No behavior change; just separation of ownership.
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.
The shadcn SelectTrigger ships without w-full, so both dropdowns were
shrinking to their label width while every other input in the form
stretches full-width. Added w-full to both triggers to match the
surrounding inputs.
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.
SocialMediaAssistant::activePlatformRules() reads post_platforms on the
attached Post, maps them to Platform enum values, and resolves rule
classes via Registry::forMany(). The rendered instructions include only
the summaries of platforms actually selected on this post — so when a
user is posting to X only, the agent doesn't waste tokens thinking
about Instagram Reels.
The PLATFORM KNOWLEDGE block in system.blade.php is replaced with
@include('prompts.assistant.platforms') which iterates the passed rules
and renders each summary. When the post has no platforms, the section
renders nothing (a @if guards it).
The agent now implements HasTools and returns three tool instances
(GenerateImage, GenerateVideo, GenerateAudio) each wired with the
workspace + post + userId context.
Replaced the MEDIA GENERATION RULES block in the system prompt. Tools
replace the old [GENERATE_IMAGE:vertical] regex convention — the LLM
now invokes tools directly with typed parameters (prompt, orientation),
which eliminates the fragile text parsing in the controller.