Commit graph

175 commits

Author SHA1 Message Date
Paulo Castellano
1660084227 refactor(social): use config for OAuth hosts everywhere
Earlier in the PR the new configs (linkedin.oauth_api, youtube.oauth_api,
bluesky.default_service, mastodon.default_instance) were only read by
ConnectionVerifier. The same URLs were still hardcoded in the publishers,
analytics and the Bluesky auth controller — meaning a self-hosted user
setting BLUESKY_DEFAULT_SERVICE or MASTODON_DEFAULT_INSTANCE in env would
get split behavior: refresh/verify honor the override, publish/analytics
don't.

Routes all 10 remaining call sites through the same config values so the
overrides actually work end-to-end.
2026-05-19 08:34:35 -03:00
Paulo Castellano
9845d15db3 refactor(media): MediaUploadResource + ws→workspace_id + cleanup 2026-05-15 16:48:09 -03:00
Paulo Castellano
7341852f5c refactor(mcp): move upload config from trypost.mcp to ai.mcp 2026-05-15 16:25:26 -03:00
Paulo Castellano
6b40edce55 refactor(mcp): extract MCP upload caps to trypost.mcp.upload config 2026-05-15 16:24:14 -03:00
Paulo Castellano
d50348562b refactor(media): atomic upload_token via transaction and tighten test assertions 2026-05-15 16:16:42 -03:00
Paulo Castellano
b17026f3a1 feat(media): signed POST upload endpoint for MCP flow 2026-05-15 16:10:21 -03:00
Paulo Castellano
c4eb83e81b chore: drop comment 2026-05-15 12:25:36 -03:00
Paulo Castellano
a96136925c fix(posts): drop redundant 'publishing' toast on publish
Show.vue already renders a full-screen overlay with spinner + the same
'post is being published' messaging while post.status === 'publishing'.
The flash toast was saying the same thing transiently — duplicate UX
that also contributed to the visual noise as Echo events triggered
partial reloads.

- Remove session()->flash() for the Publishing action in PostController
- Drop the now-orphan 'flash.publishing' key from en/pt-BR/es

Scheduled-action flash kept (Show.vue has no equivalent overlay for it).
2026-05-15 12:23:52 -03:00
Paulo Castellano
44d891ef08 fix(pinterest): restore board picker + require board_id in validation
The post editor lost the Pinterest board picker during a UI rewrite,
causing scheduled posts to fail in production with 'Pinterest board_id
is required'. This restores the picker and locks the contract with
validation + tests so the regression cannot happen silently again.

Backend:
- PostController: pinterestBoards is now Record<account_id, Board[]>
  (mirrors the TikTok creator-info pattern); supports multi-account.
- UpdatePostRequest: 'platforms.*.meta.board_id' rule + after-validator
  rejects Publishing/Scheduling Pinterest posts without board_id.

Frontend:
- PinterestSettings.vue: Combobox board picker with empty-state warning;
  emits update:meta with board_id.
- ScheduleTab / PostEditorSidebar / Edit pass pinterestBoards down by
  social_account_id.

Tests (6 new):
- UpdatePostRequestTest: rejects publishing/scheduling without board_id
  across pin/carousel/video pin; allows draft without board_id;
  pinterest error doesn't block sibling platforms in multi-platform.
- PinterestPublisherTest: publisher throws for carousel + video pin
  when no board_id (existing image-pin case kept).

1542 tests passing.
2026-05-15 11:51:20 -03:00
Paulo Castellano
0514ce677b feat(billing): clear trial_ends_at on subscription created + add tests
- StripeEventListener::handleSubscriptionCreated nulls account.trial_ends_at
  when a Stripe subscription is created. Prevents 'Trial' badge from
  lingering for users who convert mid-generic-trial to paid.
- Drop unused trialDays global Inertia prop (no frontend consumers after
  /subscribe redesign).

Tests added (10 new, 0 regressions, 1533 total):
- AccountTest: isOnTrial + activeTrialEndsAt across 4 scenarios
  (no trial, generic only, subscription only, both — subscription wins)
- StripeEventListenerTest: subscription created clears generic trial
- TrialMiddlewareAccessTest: trialing-with-card subscription passes
- BillingControllerTest: index exposes onTrial/trialEndsAt for the 3
  trial states (generic-only, subscription-only, paying); subscribe
  page no longer exposes trialDays prop
2026-05-14 20:23:35 -03:00
Paulo Castellano
c29198caef feat(subscribe): direct subscription, no trial on /subscribe
Trial now exists exclusively at signup (no-card generic trial). The
/subscribe page is reached only after the trial has been consumed (or to
upgrade plans), so it should never offer another trial — that would be a
double-trial loophole.

- BillingController::subscribe drops trialDays prop
- BillingController::checkout drops ->trialDays() call (direct charge)
- Subscribe.vue drops trialDays prop and :days placeholders
- i18n (en/pt-BR/es): new subscribe-focused copy, remove start_trial/trial_info
2026-05-14 20:05:39 -03:00
Paulo Castellano
fc90861a96 refactor(account): move trial-end-date logic into Account::activeTrialEndsAt()
Centralizes 'what date should the UI show as trial end?' on the model.
Returns null when not on trial, the subscription's trial date when on
trial-with-card, or the generic trial date for no-card users.
2026-05-14 19:55:28 -03:00
Paulo Castellano
5c03f9cd11 refactor(middleware): use Account::isOnTrial() instead of onGenericTrial()
Centralizes the 'is the user on any kind of trial?' question in the model.
Equivalent behavior — Cashier's subscribed() already includes trialing
subscriptions — but semantically cleaner and easier to extend.
2026-05-14 19:51:20 -03:00
Paulo Castellano
83f9e69eed feat(billing): surface generic trial state on billing settings + 7d default
- BillingController::index reads onTrial from Account::isOnTrial() (covers
  generic trial without a Stripe subscription) and falls back to
  account.trial_ends_at when no subscription exists. Vue page already had
  the badge + 'Trial ends' UI wired — just needed the right props.
- Drop default trial_days from 8 to 7 for consistency with messaging.
2026-05-14 19:46:34 -03:00
Paulo Castellano
177e7f8681 feat(signup): no-card 7-day trial on Starter plan
New signups land on a 7-day generic trial (Cashier trial_ends_at) without
a Stripe customer or subscription. Account is on Starter plan limits during
the trial. After 7 days, EnsureAccountReady redirects to /subscribe per the
existing flow.

- CreateUser sets account.trial_ends_at and plan_id = Starter
- EnsureAccountReady allows access when subscribed() OR onGenericTrial()
- Account::isOnTrial() includes generic trial check

Existing users unaffected: paying users have a subscription;
never-paid users continue redirecting to /subscribe.
2026-05-14 19:37:46 -03:00
Paulo Castellano
074a66f1e2 fix(linkedin-page): persist OAuth scopes through the page-picker flow
The LinkedIn Page connection has a two-step OAuth: first the
`callback` stashes the Socialite user in `linkedin_page_pending` and
redirects to the page picker, then `select` finalizes by writing the
chosen organization to social_accounts. The pending payload was missing
`approved_scopes`, and both finalize paths (`update` for reconnect,
`updateOrCreate` for first connect) never wrote the `scopes` column.

Result: every LinkedIn Page account had `scopes = NULL` in the DB,
the publish-time scope check saw `w_organization_social` as missing
and blocked every post with 'Missing permissions. Please reconnect
your account.'

Fix: stash `approved_scopes` in the session payload, then in both
finalize paths persist it with the same comma-split treatment used by
the LinkedIn personal controller (the LinkedIn-OpenID provider has the
same separator quirk — granted scopes come CSV-joined inside a
single Socialite array element).

Test: `linkedin page select splits comma-separated approvedScopes
before saving` covers the persist + split path.
2026-05-14 11:48:30 -03:00
Paulo Castellano
7d9302b064 refactor: remove debug logging from social OAuth controllers 2026-05-14 11:23:40 -03:00
Paulo Castellano
49ccfe851e fix(linkedin,pinterest): split CSV/space-joined OAuth scopes before saving
LinkedIn and Pinterest's OAuth providers return the granted scope list
joined by comma (LinkedIn) or space-in-one-element (Pinterest), but
Socialite's scope splitter doesn't match either, so 'approvedScopes'
lands as a single-element array containing the whole list:

  LinkedIn:  ['email,openid,profile,r_basicprofile,w_member_social']
  Pinterest: ['boards:read boards:write pins:read pins:write user_accounts:read']

That breaks the publish-time scope check in PublishToSocialPlatform
(array_diff does exact string compare), surfacing as
'Missing permissions: w_member_social. Please reconnect your account'
even though the scopes were actually granted at the provider.

Fix is inline at each callback — re-split before saving. Each provider
has its own quirk (LinkedIn = comma, Pinterest = space), so each
controller handles its own separator.

Tests added: callback splits the joined approvedScopes into individual
tokens for both providers.
2026-05-14 10:55:53 -03:00
Paulo Castellano
66f4d8c7b3 refactor(posts): use $request->collect() + when() for label filter
Same semantics, more idiomatic Laravel. Drops the (array) cast,
the array_values+array_filter pair, and the if (!empty(...)) guard
in favor of $request->collect() + Collection pipeline +
$query->when() conditional clause.
2026-05-14 10:02:38 -03:00
Paulo Castellano
af96cb0a0e feat(posts): multi-select label filter on the posts list
Adds a combobox-style filter to the posts index toolbar so users can
narrow All / Scheduled / Posted / Drafts views by one or more labels.

- `PostController::index` accepts `?labels[]=<id>` and applies
  `whereHas('labels', whereIn(...))` (OR semantics across selected labels).
  Workspace labels are exposed to the page (sorted by name) and the
  selected set comes back under `filters.labels`.
- New `LabelFilter.vue` component reuses the existing Popover + Command
  pattern (matching `FontPicker` in the Brand settings page). Trigger
  renders the selected `LabelBadge`s inline (mirroring how each post row
  already displays its labels): 1-3 shown directly, 4+ shown as the
  first three plus a "+N" overflow indicator. Clear button has a
  tooltip and `cursor-pointer`, and stops `click`/`pointerdown`/
  `mousedown` so it doesn't reopen the Popover.
- Existing search debounce is shared with the new label watcher via a
  single `buildFilterUrl` helper. URL is updated with `preserveState +
  replace` so the back stack stays clean.
- i18n in en / pt-BR / es: `filter_by_label`, `label_search_placeholder`,
  `no_labels`, `clear_label_filter`.

Tests: 4 new index tests covering the labels prop exposure, single-label
filter, multi-label OR filter, and blank-id sanitization. Full suite:
1509 passed, 2 skipped, 0 failed.
2026-05-14 09:57:55 -03:00
Paulo Castellano
eebe510a31 fix(billing): widen tracking exception catch so post-payment page never crashes
`$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.
2026-05-12 13:38:37 -03:00
Paulo Castellano
4efc6f467d feat(tracking): push Google Ads conversion data to dataLayer on purchase
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
2026-05-12 13:31:00 -03:00
Paulo Castellano
b10f25f94d refactor(posts): tighten content-length caps and simplify contentOverflow
- Platform::contentOverflow returns int (0 = fits) via max(), drops the
  nullable-int + ternary pattern; HasSocialHttpClient and the validation
  rule updated to compare against 0 instead of null.
- Drop the hardcoded 63206-char limit to 10000 across all entry points
  (Platform enum, MCP CreatePostTool/UpdatePostTool, FacebookRules, all
  three FormRequests). Facebook's API accepts up to 63206 but nobody
  writes 63k-char posts and emoji-heavy content risks overflowing the
  TEXT column's 65535-byte ceiling.
- Compiled i18n JSON regenerated with the content_exceeds_platform key.
2026-05-11 20:06:44 -03:00
Paulo Castellano
953be22b5b fix(posts): block scheduling when content exceeds any platform's char limit
Threads posts over 500 chars were saved + scheduled successfully and only
failed inside the publish job. The frontend already showed the 537|500 badge
but `canSchedule` ignored content length, so Schedule and Post Now stayed
enabled. Backend `UpdatePostRequest` only capped at 63206 (Facebook's max),
not per-platform.

- Add `Platform::contentOverflow()` as the single source of truth and reuse it
  from `HasSocialHttpClient::validateContentLength` (publish-time).
- New `ContentFitsPlatformLimits` rule applied to the `content` field on
  `App\\UpdatePostRequest`, `Api\\UpdatePostRequest`, and `Api\\StorePostRequest`
  via `Rule::when(...)` so drafts are not blocked.
- Rule dedupes per platform (two Threads accounts -> one error) and reports
  the platform label, hard cap, and overage via i18n.
- Edit.vue feeds `contentLengthOverflows` into `canSchedule` and lists each
  offending platform in `postActionTooltip` using the existing
  `getPlatformLabel` resolver.
2026-05-11 19:39:41 -03:00
Paulo Castellano
0682b6503c perf(tiktok): load creator_info synchronously and cache it
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.
2026-05-09 15:01:22 -03:00
Paulo Castellano
b322a7074f refactor(posts): drop hardcoded English validation messages
Removes the messages() override on UpdatePostRequest. The 4 messages it
defined were hardcoded English strings that bypassed Laravel's built-in
validation translations. With the override gone, errors flow through
lang/{locale}/validation.php which is already translated for en/pt-BR/es.
Trade-off: default messages reference the field path (e.g.
'platforms.0.content_type'). Acceptable here because the editor renders
errors inline next to each field rather than as a flat list.
2026-05-09 13:58:09 -03:00
Paulo Castellano
c5e667f92a refactor(tiktok): use i18n for content type description and validator error
- ContentType::description() now reads from posts.content_types.{value}.description
  instead of hardcoded English. Adds the missing tiktok_photo entries in en/pt-BR/es
  and syncs three Pinterest descriptions that had drifted between the i18n file and
  the previously-hardcoded enum strings (the enum strings were the user-visible source).
- UpdatePostRequest::withValidator surfaces the privacy_required validation error
  via posts.form.tiktok.privacy_required (added in en/pt-BR/es) instead of an
  English string.
- Drops a verbose 3-line comment in withValidator that explained obvious code.
2026-05-09 13:56:00 -03:00
Paulo Castellano
2c08da7787 feat(tiktok): photo carousel support + UX Content Sharing API compliance
## Photo carousel support

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

## UX Content Sharing API compliance

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

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

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

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

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

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

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

## Cross-cutting

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

## Tests

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

Full Pest suite: 1490 passed, 2 skipped (pre-existing).
2026-05-09 12:47:49 -03:00
Paulo Castellano
d97822c7e8 refactor: drop RenderedSlide DTO and content_language null fallbacks
- TemplateImageGenerator->render() now returns a typed array shape
  {path: string, source_meta: array} instead of a one-off RenderedSlide
  DTO. Single internal callsite, no need for a dedicated class.
- Drop the `?? 'en'` fallback on $workspace->content_language in 6
  callsites: the column has a NOT NULL default of 'en' at the DB level,
  so the null coalesce was dead code.
- WorkspaceFactory now seeds content_language, brand_tone, brand_font
  and image_style explicitly so make() (no DB persist) produces a
  complete model — DB defaults aren't applied until create().
2026-05-08 19:32:51 -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
cbea8394fc feat: localize OAuth popup callback messages across 12 controllers
User reported the social-account OAuth callback popup ('Threads account
connected!') stayed in English regardless of the active locale. The
hardcoded message was wired through SocialController and 11 platform
controllers (Bluesky, Facebook, Instagram, InstagramFacebook, LinkedIn,
LinkedInPage, Mastodon, Pinterest, Threads, TikTok, YouTube), plus the
Blade view that the popup renders.

Adds an accounts.popup_callback i18n block (en/pt-BR/es) covering:
- The popup chrome (title, closing/close-now status text).
- Generic success/reconnect messages (one shared 'Account connected!'
  / 'Account reconnected!' line — the popup already shows a checkmark
  and lives for ~2s so platform-specific wording wasn't pulling weight).
- Error variants (account/page/channel) and contextual edge cases
  (page not found, no Facebook pages, no YouTube channels, etc.).

Updates every popupCallback() callsite to read from these keys, plus
the Blade view's title and submessage. Regenerates the JSON locale
bundle so laravel-vue-i18n stays in sync.
2026-05-07 14:03:52 -03:00
Paulo Castellano
ff759611b9 refactor: allow yearly to monthly swaps in BillingController
Drops the abort_if guard that blocked switching from a yearly billing
cadence to monthly. The product decision was reversed — users should
be free to move in either direction without going through support.

Removes the corresponding 'swap blocks yearly to monthly downgrade'
test.
2026-05-07 11:35:36 -03:00
Paulo Castellano
dad4ab21c3 fix: yearly billing interval display in UpgradeDialog and Billing page
UpgradeDialog was reading 'currentPlan.stripe_yearly_price_id' from
'auth.plan', but auth.plan is built by AuthPlanResource which only
exposes {id, slug, name, interval} — the price ids are absent. The
'isOnYearly' computation always compared against undefined and returned
false, so users on a yearly subscription saw the dialog as if they were
on monthly: no current-plan badge match, the toggle was forced visible,
and the wrong price/billing label was shown.

Switches both UpgradeDialog and the Billing settings page to read
auth.plan.interval directly (the authoritative field already present
in shared props), drops the now-unused 'currentPriceId' from the Inertia
middleware and the Auth type. Yearly is now the default selection on
dialog open and the toggle stays visible regardless of current cadence.
2026-05-07 11:35:28 -03:00
Paulo Castellano
aadfe7d6e8 fix: do not set plan_id on checkout creation; drop redundant guards
Two issues from review:

1. BillingController::checkout was setting plan_id immediately after
   creating the Stripe Checkout session, before the user actually paid.
   If the user abandoned checkout, the account ended up with a plan it
   never paid for. Plan activation is now driven exclusively by the
   customer.subscription.created webhook, which fires only after a
   successful payment.

2. The 'if (\$account->wasChanged('plan_id')) { ... }' guards around
   forgetPlanFeatureCache() were tautological — Eloquent's update()
   already short-circuits when nothing changed, and Pennant forget()
   is idempotent, so an extra cache clear when the plan didn't move
   is harmless. Removing the guards keeps the listener and swap path
   readable.
2026-05-07 11:08:30 -03:00
Paulo Castellano
f72a97f676 refactor: explicit Pennant cache reset on plan_id change
Replaces the implicit Account::booted() observer with an explicit
Account::forgetPlanFeatureCache() method called from each plan_id
mutation site (StripeEventListener x3, BillingController x2). Self-hosted
installs naturally never reach any of these callsites — Stripe webhooks
do not fire and the billing controllers redirect to /calendar before any
plan mutation happens — so the Pennant flush is now guaranteed to be a
cloud-only operation.

Adds integration coverage proving the full chain webhook -> plan_id
update -> Pennant flush -> next Feature::value resolves against the new
plan limit.
2026-05-07 10:59:48 -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
3e642ef520 refactor: redesign billing settings + upgrade dialog with indies aesthetic
- Billing settings page restructured around the design system: dropped
  the 280px-label / 1fr-content split for stacked HeadingSmall sections;
  current plan rendered as a hero card with display-font name, price,
  amber sticker tile, and an inline primary "Change plan" CTA; payment
  method consolidated into a single sticker row with a violet card-icon
  tile and the manage CTA on the same line; empty payment-method state
  handled. Invoices keep the sticker-row treatment.
- Upgrade dialog mirrors Subscribe.vue end-to-end: planTones backgrounds
  per slug, ⭐ POPULAR / CURRENT badges absolute-positioned, ink-bordered
  rounded-full CTA pill, sticker check icons, "Everything in {plan}"
  copy, IconInfoCircle tooltip on credits, yearly/monthly toggle pill
  with rotating amber save-months badge. While a request is in flight
  every plan button is disabled and the active one shows IconLoader2
  spinner.
- Account model gains `displayablePaymentMethod()` returning the
  card array used by the UI. Resolves the customer-level default first,
  falls back to the first attached payment method (Stripe Checkout trials
  anchor the card to the subscription rather than the customer, so the
  customer-level lookup returns null even when a card exists).
- i18n: added `billing.subscription.expires_on` and `no_payment_method`
  in en/pt-BR/es.
2026-05-06 17:00:25 -03:00
Paulo Castellano
d83ac0612f feat: implement workspace management and update localization strings across multiple languages 2026-05-06 16:41:27 -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
96e1aa36b6 feat: GTM dataLayer context + checkout/purchase events
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.
2026-05-06 10:14:17 -03:00
Paulo Castellano
e3acd1bb4b revert: keep one OAuth callback URL per provider
Drops the dedicated /settings/authentication/providers/{provider}/callback
route added in the previous refactor — registering a second callback URL
in each OAuth app is more ops cost than the trade is worth.

Back to one callback URL per provider, with a small `Auth::check()`
branch in the auth controllers' callbacks. The check is safe because
the redirects that initiate the round-trip enforce the right
middleware (signup/login is `guest`-only, connect is `auth`-only),
so the auth state at callback time matches the flow's intent.
2026-05-04 19:42:13 -03:00
Paulo Castellano
79f800f858 refactor: dedicated callback URL for connect-from-settings flow
Splits the OAuth connect flow off entirely from signup/login so each
route has a single responsibility.

- routes/auth.php returns to its original state — redirect + callback
  both back inside the `guest` group.
- routes/app.php gains a paired callback route at
  /settings/authentication/providers/{provider}/callback.
- AuthenticationController::connectProvider /
  connectProviderCallback override Socialite's redirectUrl so the
  round-trip stays on the connect-flow URL. The Auth::check() branch
  in the auth controllers is gone.

The OAuth apps in Google Cloud and GitHub Developer Settings need the
new callback URL registered alongside the existing one — documented in
.env.example.
2026-05-04 19:30:16 -03:00
Paulo Castellano
d89f6ceede fix: let authenticated users connect Google/GitHub from Settings
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.
2026-05-04 19:22:09 -03:00
Paulo Castellano
1bf0bbc9ef
Merge branch 'main' into feature/capture-signup-utms 2026-05-04 19:02:49 -03:00
Paulo Castellano
2e9e0f716b feat: capture signup UTMs/IP and add GitHub OAuth login
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.
2026-05-04 18:42:25 -03:00
Paulo Castellano
24a8786cba feat: add multipart media upload endpoint and split URL flow
Adds POST /api/posts/{post}/media for direct file (multipart) upload
and renames the existing URL-based flow to /api/posts/{post}/media/from-url
so the path matches HTTP semantics (POST <resource>/media expects a file
body, not JSON URLs).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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