Commit graph

25 commits

Author SHA1 Message Date
Paulo Castellano
0eff22dc0d
Remove the post-templates feature (#296)
* Remove the post-templates feature

Removes the browsable post-templates catalog end to end: routes
(app.post-templates.index/apply), controller, form requests, resource,
Registry/PostTemplateData/TemplateNotFoundException services, the
templates:report console command, the templates/ catalog (36 files
across en/es/pt-BR), the templates Index.vue page, the template card
on the post Create.vue choice screen, and its i18n keys across all 16
locales.

Also removes TemplateContextResolver, which fed catalog examples into
PostContentGenerator's AI prompt. The "examples" block (with its
heading) is removed from generator.blade.php rather than left empty;
AI generation no longer references the templates catalog.

The AI content-template system under app/Ai/Templates (image/tweet
card templates for the AI post wizard) and TemplateImageGenerator are
unrelated and untouched.

* Update FormRequest naming examples after the post-templates removal

CLAUDE.md and the Cursor rules cited ApplyPostTemplateRequest and
IndexPostTemplateRequest as naming examples. Both classes were deleted
with the feature, so the guidance pointed at files that no longer exist.

* Share the AiTemplate type between the create screen and the AI wizard

Create.vue and AiPostWizard.vue each declared their own AiTemplate
interface, and they had drifted: the wizard's carried
applies_brand_visuals, the create screen's did not. TypeScript treated
them as unrelated types with the same name, which surfaced as a TS2719
error where the templates prop is passed between them.

The backend sends seven fields (PostController::create), so the shared
declaration follows the payload rather than the union of the two copies.
2026-08-19 09:34:26 -03:00
Paulo Castellano
a1a9172448
fix: subscribe to AI generation channel before dispatching the job (#269)
* fix: subscribe to AI generation channel before dispatching the job

AiGenerateDialog posted the generate request and only subscribed to the
broadcast channel once the response came back. Reverb's plain Channel
(vendor/laravel/reverb/.../Channels/Channel.php) delivers broadcasts by
iterating the in-memory connection list at send time only — no history,
no replay. If the queued job started streaming text_delta/stream_end
events before the private-channel subscribe handshake finished, those
events were gone for good and the dialog hung on 'streaming' forever.

Generate the generation_id client-side, await channel subscription
confirmation (Echo's channel.subscribed(), backed by
pusher:subscription_succeeded) before sending the generate request, and
have the backend use the client-supplied id instead of minting its own.

Related to #218.

* fix: handle subscribe failure, dialog-close race, and i18n hardcoded strings

Code review on the previous commit surfaced real gaps:

- useAiStream only handled the subscribe-success path (.subscribed()).
  A definitive pusher:subscription_error (expired session, CSRF
  mismatch) fell through to the 5s timeout and was treated as success,
  reproducing the exact #218 hang via a different trigger. subscribe()
  now returns a boolean: true on confirm-or-ambiguous-timeout (proceed
  optimistically), false only on an explicit error (don't dispatch work
  nothing will ever hear).
- AiGenerateDialog now bails without dispatching if the dialog is
  closed while awaiting subscription confirmation (up to 5s), and
  unsubscribes on a post-subscribe dispatch failure so stale listeners
  can't flip status to 'completed' after an error was already shown.
- Extracted aiGenerationChannel() so the frontend has one place to
  update if the channel format changes, and cross-referenced the two
  remaining backend copies (PostAiGenerateController, StreamPostContent).
- Replaced the two hardcoded English fallback error strings with i18n
  keys (posts.ai.generate.errors.*) across all 16 locales — this app
  only ships translated copy.
- Simplified subscribe(): dropped a redundant "already settled" guard
  and the clearTimeout bookkeeping — Promise.resolve() already no-ops
  after the first call, so there was nothing to guard.

* fix: subscribe before dispatch in AI post creation and image regeneration flows

Extracts subscribePrivateChannel as a shared helper and applies the same
subscribe-before-dispatch fix from the post edit flow (issue #218) to the
other two flows that stream over private channels.

Post creation wizard moves the StreamPostCreation dispatch out of
AiPostWizard.vue and into Loading.vue, so the channel is subscribed before
the job that broadcasts on it is dispatched — previously a full page
navigation happened between dispatch and subscribe with no timeout
fallback, so a lost event hung the page forever.

* refactor: trim explanatory comments added in the subscribe-before-dispatch fix

Rationale lives in the commit history, not scattered inline comments.

* refactor: trim remaining explanatory comments from the subscribe-before-dispatch fix

Same cleanup as the previous commit, applied to the files from the
original post-edit-flow fix.

* fix: dedup post-creation dispatch, surface field-level AI errors, harden edge cases

Uses ShouldBeUnique on StreamPostCreation (scoped per-user) instead of an
ad-hoc cache guard, matching the pattern already used by
PublishToSocialPlatform and VerifyUpcomingPostConnections.

Field-level validation errors (prompt/instruction) now render inline via
InputError, matching how every other form in the app surfaces them, instead
of a generic status banner. Also fixes a 422 response missing the `errors`
key that Inertia's client silently swallows, adds error handling around the
wizard's navigation to the loading page, and extracts the duplicated
error-message parsing into a shared helper.
2026-08-10 16:06:37 -03:00
Paulo Castellano
a147c7414b
feat: proactive connection check for at-risk posts + SocialAccount name centralization (#256)
* chore: gitignore .superpowers/ scratch workspace

Holds per-plan subagent-driven-development artifacts (ledger, briefs,
review packages) — scratch state, not part of the shipped codebase.

* feat: add connection_warning_sent_at to post_platforms

* feat: add PostAtRisk notification type and translations

* fix: add user_id to NotificationPreferenceFactory definition for ->create() support

* feat: add PostAtRisk mailable and email template

* feat: add VerifyUpcomingPostConnections job

* fix: guard VerifyUpcomingPostConnections against transient errors and cross-workspace leaks

- Add a generic \Exception catch around ConnectionVerifier::verify() so a
  transient error (e.g. ConnectionException) on one account can't abort
  processing of every other at-risk account in the workspace run.
- Eager-load socialAccount.workspace so markAsTokenExpired's observer chain
  never lazy-loads it — this only ever manifested once 2+ distinct accounts
  were hydrated in a single run (Eloquent only sets preventsLazyLoading on
  batch hydration of >1 row), which is exactly the multi-account scenario
  this job exists to handle.
- Add covering tests: enabled=false posts are excluded, one workspace's
  at-risk posts never leak into another workspace's notification, and an
  unexpected exception on one account doesn't stop the rest of the run.

* feat: add social:check-upcoming-connections command and schedule it

* fix: add composite index for the 15-minute upcoming-post connection query

post_platforms(status, connection_warning_sent_at) supports the filter both
VerifyUpcomingPostConnections and social:check-upcoming-connections run every
15 minutes; without it, every run does a full table scan that only grows as
posts accumulate.

* fix: localize the PostAtRisk email's per-account line and label times as UTC

The postsLabel line was the only hardcoded-English content in an otherwise
fully-translated email, and it showed scheduled_at times with no timezone
indicator even though the app stores everything in UTC. Add
mail.post_at_risk.posts_label (pluralized, one entry per locale, mirroring
each locale's existing post_at_risk.subject plural-boundary syntax) and use
trans_choice() to build the line, with a literal " UTC" suffix left
untranslated in every locale like a unit abbreviation.

Also document why content() reassigns the public $atRiskGroups property
instead of using a local variable (Mailable::buildViewData() overwrites
with() data with same-named public properties).

* fix: time-box the warning dedup and guard against orphaned/ownerless rows

- Re-arm connection_warning_sent_at after a day instead of permanently
  suppressing it, so a post rescheduled back into the risk window after a
  stale warning is re-evaluated instead of silently skipped forever.
- Exclude post_platforms with a null social_account_id from the at-risk
  query. With tries=1, dereferencing a null socialAccount relation would
  abort the whole workspace run, including already-detected broken accounts.
- Resolve and check the workspace owner before stamping
  connection_warning_sent_at, so an ownerless workspace's posts are left
  un-warned (available to be picked up once it gets an owner) instead of
  being marked "warned" with no notification ever sent.

Applied the same dedup time-boxing and null-account guard to the
social:check-upcoming-connections dispatch query for consistency.

* fix: PostAtRisk email is always English — drop the locale translation layer

config('app.locale')/App::setLocale() is only ever set by the SetLocale
web middleware, which reads a cookie off the incoming HTTP request. Every
Mailable in this branch is built inside a queued job (SendNotification),
which runs outside the HTTP request lifecycle entirely — no middleware,
no cookie, nothing sets the locale there. So content() always resolved
'app.locale' to the static APP_LOCALE default ('en') regardless of the
recipient's actual preference: the 16-locale mail.post_at_risk.* keys
were dead weight from the start, matching an existing (pre-existing,
out of scope here) gap in the sibling WorkspaceConnectionsDisconnected/
AccountDisconnected mailables.

Replaces the trans_choice()/__() calls with plain English strings built
directly in PostAtRisk, and removes the now-unused mail.post_at_risk.*
block from all 16 locale files. Also strengthens the mailable test to
assert the full "N post(s) scheduled: ... UTC" string, not just a
fragment of it.

* refactor: consolidate the two post_platforms migrations from this branch into one

connection_warning_sent_at and its supporting index were added in two
separate migrations (the column in the original task, the index during
final review). Both are still unmerged/unshipped on this branch, so
folding the index into the same migration that adds the column is safe
and keeps the schema change to post_platforms as one unit instead of two.

Verified with a full rollback + re-migrate cycle that the consolidated
up()/down() is self-consistent.

* refactor: add PostPlatform::scopeEnabled(), replace ->where('enabled', true) everywhere

The raw where('enabled', true) clause was duplicated across 17 call sites
in 12 files (13 including the 2 this branch added), all expressing the
same rule PublishPost enforces at publish time: only enabled platforms
are eligible. Added a scopeEnabled() to PostPlatform and swapped every
query-builder call site to ->enabled().

Three call sites are intentionally left untouched: they filter an
already-loaded relation Collection (->postPlatforms->where(...), no
parens), which is Collection::where(), not a query scope — a query scope
can't apply to an in-memory collection.

No inverse (enabled = false) query pattern exists anywhere in the
codebase — 'enabled' => false only ever appears as a write when a post
is disabled/synced, never as a read filter — so no scopeDisabled() was
added; nothing would call it.

* test: cover re-armed post_platform where the account was reconnected

The re-arm dedup fix (connection_warning_sent_at older than a day is
treated as null) only had coverage for "still broken, warns again" and
"too recent, stays skipped". Missing: the row gets re-evaluated (verify()
is called, not skipped) but comes back healthy because the user
reconnected in the meantime — nothing should change (no new warning, no
notification, marker stays at its old value).

* fix: dispatch-level uniqueness, index the enabled filter, close markAsTokenExpired race

From a deep review pass on the whole branch:

- VerifyUpcomingPostConnections now implements ShouldBeUnique (keyed on
  workspaceId, 300s window). withoutOverlapping() on the schedule only
  serializes the fast-dispatching command; a queue backlog could still let
  two jobs for the same workspace run concurrently, both mailing the owner
  for the same at-risk posts.
- The composite index now covers enabled too (status, enabled,
  connection_warning_sent_at) — every query that uses it filters on all
  three, so the index previously required a heap fetch per row just to
  check enabled.
- markAsTokenExpired() silently no-ops if it loses the account's status
  lock to a concurrent process (a publish attempt, the daily check). The
  job used to push the account into the at-risk notification regardless
  of whether the update actually landed. It now re-checks the account's
  status after the call and only warns if the transition is confirmed —
  a lost race just defers the account to the next run instead of sending
  a misleading "reconnect" email for an account whose status didn't change.

Also includes an unrelated stray Pint fix (inline \Throwable -> imported)
in SendNotification.php that had been sitting uncommitted.

* refactor: centralize account handle/display name, expose to frontend, close review findings

Adds SocialAccount::handle()/accountDisplayName() plus appended
display_label/handle_label JSON fields, replacing duplicated
username/display_name fallback logic scattered across platform
previews, NetworkConnectGrid, PreviewTab, Calendar, and the post
editor pages.

Also closes the remaining findings from the final review on this
branch: escapes the workspace name in PostAtRisk's intro (and drops
the now-unnecessary raw-HTML rendering), fixes the tautological
"dispatches once per workspace" test, adds plural/subject test
coverage for PostAtRisk, raises VerifyUpcomingPostConnections'
uniqueFor to cover the full schedule cadence, and updates a stale
docblock.

* test: cover draft-post exclusion, account status after PlatformUnavailableException

Adds the two coverage gaps left open by the last review: a post still
in Draft status inside the 1-hour window must not trigger a check or
warning, and a PlatformUnavailableException must leave the account
status untouched. Also drops the dedicated PostAtRisk XSS test — the
intro is now plain Blade-escaped text, so the coverage is redundant
with the framework's own escaping.

* fix: close final review findings — i18n notification, empty-string fallback, missed refactor sites

- Localize the in-app "post at risk" notification title in all 16
  locales via trans_choice (the email stays English, unchanged)
- Use ?: instead of ?? in handle()/accountDisplayName()/handleLabel()
  so an empty-string username/display_name still falls back, matching
  the old Vue || behavior
- Migrate the 3 frontend sites the earlier sweep missed (Index.vue,
  SocialAccountsGrid.vue, ScheduleTab.vue) to display_label/handle_label
- Fix avatar-initial fallback in the platform preview components to use
  display_label instead of raw display_name
- Correct handle_label's TS type to string | null across 10 files to
  match the accessor's actual return type
- Add test coverage for the command-level "already warned" dedup path
  and the in-app Notification row created alongside PostAtRisk's email

* fix: notification storm, duplicate-email race, and queue payload bloat in upcoming-post checks

Three correctness issues found by review, fixed after discussion:

- An already-broken account could get a fresh PostAtRisk email every
  15 minutes for as long as it stayed broken, if new posts kept
  entering the 1-hour risk window. Gated with a per-account 60-minute
  renotify cooldown.
- Two concurrent jobs (RefreshExpiringTokens and this one) could each
  discover the same dead token and send their own email for it
  (AccountDisconnected + PostAtRisk) within the same tick. Gated with
  a 5-minute grace period, applied only when another process already
  transitioned the account before we got to it — not when we're the
  one making the transition.
- PostAtRisk carried full SocialAccount/PostPlatform/Post model
  graphs on the queue payload, since SerializesModels can't reduce
  models nested inside a plain array/Collection to lightweight
  identifiers. It now carries only post_platform IDs and rehydrates
  at send time, with envelope()/content() sharing one memoized query
  so their counts can't disagree.

Also replaces the account-health cache with a persisted
SocialAccount.last_verified_at column, and narrows the actual
platform API calls to only fire once a post's nearest scheduled_at
is within 30 minutes — enough lead time to reconnect, without
spending API budget checking a full hour out.

* fix: replace dead unsubscribe link with notification preferences, finish display_label sweep

The shared mail footer's unsubscribe link was permanently dead code
(unsubscribe_url was never passed by any Mailable). Replaced it with
a fixed "Manage notifications" link to the real settings page,
via route('app.notifications.preferences').

Also closes out the remaining sites still computing the
username/display_name fallback locally instead of reading the
backend-computed display_label: 8 more Vue components (platform
previews, per-platform post-editor settings, the AI post wizard, the
automation Generate node config, and the analytics account selector)
plus two PHP call sites (PostPlatform::getDisplayNameAttribute(),
already fixed on main before this branch, and the template image
generator's rendered footer text).

* fix: only show "Manage notifications" on preference-driven emails

The link doesn't make sense on transactional emails that always send
regardless of notification preferences (password reset, email
verification) or that go to recipients who may not even have an
account yet (workspace invite) — and the settings page it points to
requires login, which is actively broken for the first two.

Split the shared footer into two Maizzle components: footer.html
(plain) for the 3 transactional templates, footer-authenticated.html
(adds the link) for the 6 that go through SendNotification and
respect the recipient's notification preferences.

* fix: lock PostAtRisk's subject to the dispatch-time count, expose handle_label from analytics

PostAtRisk's subject/previewText were recomputed from a fresh DB
query at send time, while the in-app notification's title (built in
VerifyUpcomingPostConnections::notifyOwner()) used the count observed
at dispatch time. If a post_platform row disappeared in between, the
two could disagree. The count is now passed into the mailable
explicitly and reused for both — the body's account/post details
still rehydrate fresh from the DB, preserving the anti-staleness fix
from earlier in this branch.

Also adds handle_label to AnalyticsController's account payload,
matching every other endpoint that serializes a SocialAccount.

* fix: don't abort the whole workspace run if an account is deleted mid-verify

An exception thrown inside a catch block isn't routed to a sibling
catch, so $account->refresh() throwing ModelNotFoundException (the
user disconnected/deleted the account in the brief window between
this job loading it and handling the TokenExpiredException) escaped
handle() entirely. With tries = 1, that killed the run for every
other account in the same workspace, not just the deleted one.

Also fixes an inconsistent placeholder in PlatformPreview.vue
(handle_label: null instead of '', matching display_label).

* fix: guard against deleted accounts, guarantee a non-empty account name

Closes the last 4 findings from the sixth review round:
- VerifyUpcomingPostConnections now skips a group whose account
  resolved to null (deleted between the main query and its eager-loaded
  relation), instead of an unguarded property access aborting the
  whole workspace's run
- the same job's nested exception handler now covers any \Exception
  from markAsTokenExpired() (lock/DB failures), not just
  ModelNotFoundException
- PostAtRisk drops a rehydrated group whose account no longer exists
  instead of crashing the render (verified: fails without the fix,
  passes with it)
- AnalyticsController's handle_label field is now actually consumed by
  AnalyticsAccountSelector.vue instead of being unused payload

Also closes a real gap: every connector requests enough OAuth scope to
populate at least one of username/display_name (confirmed for TikTok,
whose account.py comment implied otherwise but whose connect() scopes
always include user.info.profile), so accountDisplayName()/handle()/
displayLabel/handleLabel now return a guaranteed non-empty string
(falling back to the platform label only as a last resort) instead of
being nullable. This removes the now-pointless @if guards around
accountDisplayName() in the account-disconnected and post-at-risk
email templates, and lets ~30 frontend files drop the `| null` from
display_label/handle_label and the ?? undefined fallbacks that only
existed to satisfy that type.

* fix: drop the now-pointless ?? '' fallback on display_label in TemplateImageGenerator

display_label is a guaranteed non-empty string (see 950558b4).

* fix: correct social_account's TS type to nullable in Index.vue and Calendar.vue

Both declared social_account as required while their own templates
used optional chaining (pp.social_account?.display_label) — the type
was lying. social_account_id is nullable and the account can be
deleted (FK is nullOnDelete), so the field genuinely can be null.

Swept every other social_account/socialAccount field in resources/js
for the same mismatch; all others already declared it correctly.

* Centralize avatar-initial extraction via getInitials()

Replace hand-rolled .charAt(0)/.charAt(0).toUpperCase() avatar-initial
logic across social account previews, the accounts grid, the analytics
account selector, and the mention picker with the existing
useInitials() composable already used by Avatar.vue.

* Drop pointless display_label fallbacks now that it's always populated

display_label is guaranteed non-empty (falls back to the platform
label server-side), so || 'Channel' / || 'TryPost' / ?? platform were
unreachable.

* Fix cold-review findings: dead handle_label guard, slug leak, wrong post count

- AnalyticsAccountSelector: the "@handle" line's guard/value must read the
  raw username (nullable — Facebook Pages and Telegram channels legitimately
  have none), not handle_label, which always resolves to something and made
  the guard permanently true. Drop the now-orphaned handle_label field from
  the analytics payload/type since nothing else in analytics used it.
- PlatformPreview: the no-account-selected fallback now uses
  getPlatformLabel() instead of the raw platform slug, matching the
  backend's own last-resort label fallback.
- VerifyUpcomingPostConnections: count distinct posts (post_id), not
  post_platform rows, so one post spanning multiple broken accounts doesn't
  inflate the at-risk count in the email subject and notification title.

* Fix cold-review round 2: silent Telegram/Discord false negative, flaky email ordering, dead display_name

- VerifyUpcomingPostConnections: ConnectionVerifier::verify() reports a
  dead Telegram/Discord connection by returning false rather than
  throwing. The job discarded that return value, so a bot removed from
  a channel/guild was stamped last_verified_at and silently trusted
  healthy for the next 40 minutes — no warning, post just fails at
  publish time. Route a false return through the same
  TokenExpiredException handling used by every other platform.
- PostAtRisk: atRiskGroups() had no ORDER BY, so the per-account
  "N posts scheduled: H:i, H:i UTC" line rendered in arbitrary
  (physical row) order. Sort by scheduled_at before formatting.
- Drop the orphaned display_name field from the analytics payload/type
  (superseded by display_label; nothing in resources/js/components/
  analytics or pages/analytics read it).

* Add social icons and copyright to email footers

Icons match the trypost-site footer (outline @tabler/icons style,
converted to PNG since email clients — notably Outlook desktop — don't
render inline SVG). Reordered footer content: tagline, manage-notifications
link, icons as the closing element, copyright line last.

* Standardize connection-verify error classification across all 13 platforms

Every platform now follows one contract: verify() returns true on a
healthy connection, throws TokenExpiredException only on a confirmed
dead connection, and PlatformUnavailableException on anything else
(rate limit, 5xx, unrecognized). Previously most platforms silently
returned false on anything but a 401, so callers (all of which only
react via try/catch) could never distinguish "definitely dead" from
"transient" — and Telegram/Discord never threw at all.

Each platform's "is this confirmed dead" check now lives next to its
existing publish-time error classifier (App\Exceptions\Social\*PublishException)
instead of being re-typed inline in ConnectionVerifier, closing real,
already-drifted gaps between the two paths:

- TikTok and Mastodon both had a bare "status === 401/403" check shared
  between publish and verify, but TikTok's scope_not_authorized and
  Mastodon's write-scope 403 use the same status for a non-fatal scope
  gap, not a dead token — verify's lower-privilege endpoint keeps its
  own stricter check on top instead.
- Telegram/Discord authenticate with one bot token shared across every
  connected account; a 401 means that shared token is misconfigured
  (an operator problem), never that one specific account is broken —
  excluded from both platforms' confirmed-dead checks accordingly.
- Facebook/InstagramFacebook/Mastodon/Telegram/Discord have no
  per-account refresh flow at all, so a confirmed rejection now skips
  the pointless refresh-and-retry (Platform::hasTokenRefreshFlow()).

Also fixes two bugs found while hardening VerifyUpcomingPostConnections:
a post hard-deleted mid-run could crash the whole job for every other
account in the batch (now filtered per group), and two overlapping runs
of the same job could send duplicate PostAtRisk warnings (now a
conditional claim on connection_warning_sent_at).

* Skip paused accounts in upcoming-post connection checks, close claim race

A paused (is_active=false) social account already fails at publish time
before any platform API call, so it shouldn't trigger a proactive
connection check or "reconnect" warning. Guard added at dispatch time
(CheckUpcomingPostConnections) and re-checked fresh mid-run inside
VerifyUpcomingPostConnections's per-account loop, since the job can take
real wall-clock time working through a workspace and an account can be
paused or deleted after the query-time guard already ran.

Also wraps the connection_warning_sent_at claim in a SELECT ... FOR UPDATE
transaction (ordered by id, 3 retries) to close a race between two
overlapping runs of the same job double-claiming and double-emailing about
the same post_platform.

* Clarify "commit" wording in claim-transaction comment

Reads ambiguously as a git commit on a PR diff; it means the DB
transaction commit.
2026-08-09 11:10:39 -03:00
Paulo Castellano
2c2ab69e68 Merge main + finalize AI brand-colors toggle
Resolves the AiPostWizard conflict and completes the feature:
- i18n parity: brand_colors_label + brand_colors_description in all 15 locales
  (was en/es/pt-BR only, which broke LocalizationParityTest).
- Reworked the two-button toggle into a Switch with an explanatory description
  (matches the settings Switch/card pattern).
- Only shown for templates that honor the flag: added appliesBrandVisuals() to
  the AiContentTemplate contract (ImageCard=true, tweet cards=false), exposed as
  applies_brand_visuals in the create-page DTO, and gated the toggle on it — so
  it no longer appears (as a no-op) for tweet-card styles.
- Tests: TemplateContractTest covers appliesBrandVisuals for all templates.
2026-07-18 15:12:26 -03:00
Paulo Castellano
e7f93f4aef refactor(ai-create): clarify prompt-rule naming and label the counter
- Rename AiPromptRules::promptRule() to wizardPromptRule() so the asymmetry is
  explicit: only the create wizard carries a minimum; the editor's generation
  reuses just the shared maximum.
- Add aria-live and a data-testid to the prompt counter so the over-limit state
  is announced to assistive tech and reachable from browser tests.
2026-07-17 11:26:15 -03:00
Paulo Castellano
3dbb8e6f7e fix(ai-create): sync the prompt limit across front and back
The counter added earlier drifted from the backend in two ways: it counted
UTF-16 code units over the raw (untrimmed) value, while the backend measures
Unicode characters (mb_strlen) over the trimmed value that is actually sent —
so emoji or trailing whitespace could falsely turn the counter red and block
the button. The 2000 limit was also copied into three places, and the wizard's
frontend `>= 3` minimum had no backend counterpart.

- Add App\Support\AiPromptRules as the single source of truth for the prompt
  bounds; both StartPostCreationRequest and GeneratePostContentRequest use it.
- Add min:3 to the create wizard endpoint so front and back agree (the editor's
  generate-content flow keeps `required` — it has no counter to mirror).
- Count code points over the trimmed value in AiPostWizard so the counter and
  the submit gate match what the backend validates, matching AltTextDialog.
- Cover min/max/boundary in PostAiCreateTest.
2026-07-17 11:03:41 -03:00
Paulo Sérgio Dantas
67fb40d177 feat(ai-create): let users choose brand colors or free AI colors for images
The image pipeline already threads `applyBrandVisuals` through
`TemplateContext` -> `PostImagePipeline` -> `TemplateImageGenerator`, but it was
hardcoded to `true` at the dispatch site, so generated images always used the
workspace brand palette with no way to opt out.

Expose the choice in the create wizard: a "Brand colors" / "Let AI decide"
toggle (shown only when images are generated). The flag flows
front -> `StartPostCreationRequest` (`apply_brand_visuals`) ->
`PostAiCreateController@start` -> `StreamPostCreation` -> `TemplateContext`,
defaulting to `true` so existing behavior is unchanged when the field is absent.
2026-07-01 01:47:57 -03:00
Paulo Sérgio Dantas
ffd7b45eb3 feat(ai-create): show character counter under the AI prompt field
The AI post prompt is validated with `max:2000` on the backend, but the
create form gave no hint of the limit — users could write past it and only
find out via a validation error on submit.

Add a live `X/2000` counter below the "What is this post about?" textarea.
It turns red (`text-destructive`) once the prompt exceeds the limit, and the
generate button is disabled while over it (`canSubmit` now checks
`length <= PROMPT_MAX`), so the limit is caught before the request.
2026-07-01 01:22:54 -03:00
Paulo Castellano
f3965b22f1 refactor(ai-templates): shared ContentStylePicker used by wizard + automation node 2026-06-17 20:11:09 -03:00
Paulo Castellano
e886db5621 feat(ai-templates): choose visual style after the format, on the same screen 2026-06-17 18:45:03 -03:00
Paulo Castellano
bb55e14423 fix(ai-templates): paragraph spacing bug, registry cache, coverage gaps, empty-account UX 2026-06-17 18:09:02 -03:00
Paulo Castellano
799a151321 feat(ai-templates): template picker in the AI wizard 2026-06-17 17:42:23 -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
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
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
8dfa664fa6 fix(facebook): restrict Page Stories to video-only
Facebook Stories do not work reliably with images via the Graph API.
Align validation and publisher with video-only (like Reel), while
Instagram Story continues to accept image or video.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 21:21:50 -03:00
Paulo Castellano
fda8f63ac1 refactor: remove unused imports and dead code across frontend components and pages 2026-05-08 20:12:56 -03:00
Paulo Castellano
ff1cc63d9b refactor: introduce VideoPreview component and standardize media handling across post previews 2026-05-08 18:30:11 -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
554b86d223 feat: useWorkspaceEcho / usePostEcho composables and realtime list refresh
Two new composables under resources/js/composables/echo/ that abstract
channel naming for the two Reverb subscription patterns we now use:

- useWorkspaceEcho — list/dashboard views: 1 subscription per workspace
  delivers events for many entities. Resolves currentWorkspace.id from
  Inertia shared props; no-ops when there is no workspace yet.
- usePostEcho — focused views (Show, Edit): per-post channel.

Posts index now subscribes via useWorkspaceEcho to the 3 lifecycle
events (post.created / post.deleted / post.platform.status.updated)
and reloads the posts prop on receipt. `reset: ['posts']` is required
on the reload — without it, InfiniteScroll's merge prop appends every
broadcast onto the existing list and produces duplicates.

Show / Edit migrated from raw useEcho to usePostEcho; AiGenerateDialog,
NotificationBell and AiPostWizard updated to the new event names from
the namespaced convention. AiStream composable moved into the same
echo/ folder for organisation; its bound event names (TextDelta /
StreamEnd / Error) come from the laravel/ai package and stay as-is.

Bonus: comments tab send button was overriding shadcn's size="icon"
(40px) with class="size-9" (36px), which left it visibly shorter than
the textarea. Removed the override and matched the textarea's
min-h to size-10 so both render at 40px.
2026-05-07 16:02:54 -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
acdc78f8bd refactor: standardize UI components, modernize localization structure, and remove legacy appearance management middleware. 2026-05-06 14:26:18 -03:00
Paulo Castellano
afc47e9532 feat: transition AI usage from feature-based limits to a centralized monthly credit system with token tracking. 2026-05-03 19:36:26 -03:00
Paulo Castellano
028fe1fefd feat: add image title and body fields to AI post creation and register PostTemplateSeeder in local environments 2026-05-03 10:06:02 -03:00
Paulo Castellano
1e1519876d feat: replace legacy AI assistant with modular post content generation, review, and template management system 2026-05-03 09:36:50 -03:00