Commit graph

413 commits

Author SHA1 Message Date
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
479011b0b3
Merge pull request #25 from trypostit/feature/tiktok-photo-and-ux-compliance
feat(tiktok): photo carousel support + Content Sharing API UX compliance
2026-05-10 14:05:14 -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
b9fd483894 fix(posts): include Pinterest in PLATFORM_VARIANTS — same default-mismatch as TikTok
Pinterest has the same UX bug: default content_type is pinterest_pin
(image-only, 1 file). If the user attaches a video, the tile would
silently disable; if they attach 2+ images, also disabled — even though
pinterest_video_pin and pinterest_carousel exist as alternatives.

List all three Pinterest variants so the tile's lenient compliance
check considers each, and togglePlatform snaps to whichever fits the
attached media on selection.

Instagram, Facebook, and LinkedIn intentionally stay out: their defaults
(feed/post) accept both images and videos, so there's no silent block.
Including them would mask real per-variant errors (e.g. Reel + image
incompatibility) — that's a user-visible mistake we want surfaced, not
hidden at the tile level. The map is documented to that effect.
2026-05-09 14:29:46 -03:00
Paulo Castellano
314ea03a1e refactor(posts): extract firstCompatibleVariant helper for platforms with variant pickers
Replaces two TikTok-specific code paths (one in platformIssues, one in
togglePlatform) with a single generic helper. PLATFORM_VARIANTS maps
each platform that exposes multiple content types via a variant picker
to the list of those content types; firstCompatibleVariant returns the
first variant that fits the current media (or null when none fits).

Both consumers — the tile-level compliance check and the on-select
content_type snap — now share the same helper. Adding another platform
later (Instagram already has multiple variants if its default ever
changes) only requires extending the map, no logic changes.

Net effect: -15 lines, no TikTok-specific branching in logic, only data.
2026-05-09 14:24:44 -03:00
Paulo Castellano
967c1481d3 fix(tiktok): tile-level compliance is lenient when media has another viable variant
Reverts the previous reactive watcher approach. Replaces it with two
small, declarative changes:

1. platformIssues for TikTok now checks whether ANY variant
   (tiktok_video / tiktok_photo) accepts the current media. The tile
   only stays blocked when neither variant fits (e.g. a GIF, mixed
   media). This matches the platform's real capability — TikTok does
   accept both videos and photos — and stops disabling the tile based
   on the post-platform's stale default content_type.

2. togglePlatform, when SELECTING TikTok, switches the post-platform's
   content_type to the variant that matches the first attached media
   (tiktok_video for video, tiktok_photo for image). Runs only on
   selection, not as a watcher — no implicit reactivity, no side
   effects on subsequent media changes. The variant picker inside
   TikTokSettings remains the user's manual override.

Reproducer fixed: new post → attach image → TikTok tile is now
clickable instead of greyed out with 'Only videos are allowed for
this format'. After clicking, content_type is tiktok_photo.
2026-05-09 14:22:15 -03:00
Paulo Castellano
28e368796a fix(tiktok): auto-switch content_type when attached media changes
The TikTok platform tile was getting silently disabled when the user
attached images before selecting TikTok: a fresh post defaults TikTok
to tiktok_video, the compliance check rejects images for video posts,
and platformIssues marks the tile as un-clickable.

Watch the media ref. If the first attached item is an image and the
TikTok post-platform's content_type is still tiktok_video (or vice
versa for video media on tiktok_photo), flip the content_type to match.
The variant picker inside TikTokSettings can still override afterwards;
this only covers the gap between adding media and opening the panel.

Reproducer: new post → attach image → click TikTok tile → previously
disabled with 'Only videos are allowed for this format', now enabled
and content_type lands on tiktok_photo.
2026-05-09 14:16:48 -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
9b42701a9d fix(tiktok): use description field for photo posts (4000 char cap)
TikTok's photo endpoint caps the 'title' field at 90 UTF-16 runes but
allows 'description' up to 4000. Today the publisher always sends the
caption in 'title', which would silently fail for any photo caption
longer than 90 chars. Split buildPostInfo into buildVideoPostInfo
(uses title) and buildPhotoPostInfo (uses description, omits Duet/
Stitch/AIGC since they don't apply to photos).
2026-05-09 11:35:55 -03:00
Paulo Castellano
facc02a30a feat(tiktok): add TikTokPhoto content type for photo carousels
Adds the enum case TikTokPhoto = 'tiktok_photo' with maxMediaCount 35,
supportsImage true, supportsVideo false, 1:1 aspect ratio. The publisher
service already calls /post/publish/content/init/ correctly — this
enum case is the missing data layer that lets validation, UI, and
defaults treat photo posts as a first-class content type.
2026-05-09 11:27:24 -03:00
Paulo Castellano
45b5e31db0
Merge pull request #23 from trypostit/fix/post-show-media-order-and-lightbox
fix: post show layout matches editor and lightbox supports navigation
2026-05-08 21:23:51 -03:00
Paulo Castellano
35fd795ac8 refactor: replace custom lightbox implementation with ImagePreviewDialog component in post view 2026-05-08 21:21:09 -03:00
Paulo Castellano
47cf7392e8 feat: structured carousel script with hook/development/proof/cta roles
Replaces the vague "intro → development → conclusion" guidance in the
generator prompt with an explicit per-slide arc: open with a specific
real urgent problem, develop the idea while creating swipe motivation,
prove with concrete evidence (result/before-after/BTS/learning), and
close with a single specific next action. Includes a slide_count to
role-distribution table (2 through 6+ slides).

Adds a `role` enum field to each slide in the carousel JSON schema
(hook, development, proof, cta) so the structure is enforced via SDK
not just prompt text. Field is additive — handleCarousel still reads
only title/body/image_keywords, so consumers are unaffected.
2026-05-08 21:05:50 -03:00
Paulo Castellano
26e7688e96
Merge pull request #22 from trypostit/feature/posts-realtime-status
feat: AI image generation overhaul + post creation flow + realtime broadcast
2026-05-08 20:14:59 -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
2519dc1fc8 refactor: standardize script import order and clean up unused route imports 2026-05-08 19:57:23 -03:00
Paulo Castellano
b1af5eccf0 chore: drop verbose comment on refreshPosts 2026-05-08 19:37:59 -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
4f3d9ff015 refactor: prefer string interpolation over concatenation for channel names
Convert all 'foo.'.$bar style concatenations to "foo.{$bar}" interpolation
in event broadcast channels (PostCreated, PostDeleted, PostCommentCreated,
PostPlatformStatusUpdated, NotificationCreated) and the related event
tests.

Document the convention in CLAUDE.md so new code follows the pattern.
2026-05-08 19:22:01 -03:00
Paulo Castellano
d15450d2f0 feat: lightbox supports navigation, video, and click-outside-to-close
- Refactor ImagePreviewDialog to expose open(url, type) / openCollection(items, idx)
  via defineExpose; drop external props/state. GalleryBrowser and
  PostEditorComposer now call lightbox.open()/openCollection() instead of
  managing previewItem/previewIndex locally.
- Lightbox closes when clicking the empty space inside the wrapper (between
  image and arrows) using @click.self. Image click also closes; video click
  triggers play/pause without bubbling to close. Arrow buttons keep
  @click.stop so navigation never closes.
- Assets gallery (uploads / Unsplash / Giphy) now opens the lightbox in
  collection mode so the user can navigate the full tab list with arrows
  or keyboard.
- Add 'delete' typed-confirmation on the post-delete dialog (Index +
  Edit pages) and on the asset-delete dialog using the new
  common.confirm_modal.delete_keyword translation (en: 'delete',
  pt-BR: 'deletar', es: 'eliminar').
2026-05-08 19:16:38 -03:00
Paulo Castellano
a1d5589f2d test: use route() helper for non-uuid loading test 2026-05-08 18:44:32 -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
ff23b5084b chore: drop verbose docblocks across PostHog and billing files
Class names and method signatures already explain what these classes
do. Project guideline (CLAUDE.md): comments only when the WHY is
non-obvious — implementation details belong in commits, not noise on
top of every class. Kept the one comment that earns it: the (int)
cast in HasUsage::cachedPostCount, which documents the load-bearing
workaround for Laravel's Redis cache numeric optimisation.
2026-05-07 16:03:12 -03:00
Paulo Castellano
b682fd4359 fix: comments tab send button size matches textarea (40px) 2026-05-07 16:03:06 -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
c627c532b3 chore: drop unused SubscriptionCreated event
The event was dispatched by StripeEventListener but had no consumers
on either side (no Event::listen registration in PHP, no Echo listener
in JS, no subscriber on the users.{owner_id} channel). Removed along
with its tests; the listener no longer references it. Cleans up the
StripeEventListenerTest to match (drops Event::fake(SubscriptionCreated)
in five negative-path tests, replaces with Bus::assertNotDispatched(
TrackBilling) which is the actual cascade-side-effect we care about).
2026-05-07 16:02:36 -03:00
Paulo Castellano
06e72831f6 feat: workspace-scoped broadcast channel and post lifecycle events
Adds a private workspace channel (WorkspaceChannel, authorised by
membership) so list views can subscribe once and receive events for
every post in the workspace, instead of opening N per-post channels.

New events:
- PostCreated   (broadcast on workspace.{id})
- PostDeleted   (broadcast on workspace.{id}; carries primitive ids so
  it fires after \$post->delete())
- PostPlatformStatusUpdated now broadcasts on both post.{id} and
  workspace.{id} so focused views and lists share the same trigger.

All broadcast events migrated to the namespaced 'entity.action'
convention from Laravel's broadcasting docs and switched from
ShouldBroadcastNow to ShouldBroadcast on a dedicated 'broadcasts'
queue (added to the supervisor-1 list in config/horizon.php) to keep
HTTP responses fast:

  PostCreated                  -> post.created
  PostDeleted                  -> post.deleted
  PostPlatformStatusUpdated    -> post.platform.status.updated
  PostCommentCreated           -> post.comment.created
  NotificationCreated          -> notification.created
  PostCreationReady            -> ai.creation.completed

Drops the SubscriptionCreated event — it was broadcast on
users.{owner_id} but had no listeners (frontend or backend) and the
billing/PostHog flow already handles plan-change tracking elsewhere.

PostPlatformStatusUpdated payload trimmed to {post_id} since every
consumer (Show, Edit, Index) does router.reload({ only: [...] }) and
ignored the rich shape.
2026-05-07 16:02:22 -03:00
Paulo Castellano
e653c900db
Merge pull request #21 from trypostit/hotfix/has-usage-int-cast
fix: cast cached post count to int (Redis serializer crash)
2026-05-07 14:32:09 -03:00
Paulo Castellano
e35b8df86a fix: cast cached post count to int and align local cache default to redis
Production crashed on every Inertia request after the PostHog branch
landed:

  TypeError: App\Models\Account::cachedPostCount(): Return value must
  be of type int, string returned at app/Models/Traits/HasUsage.php:80

Root cause: Laravel's RedisStore optimises is_numeric values by storing
them raw (not serialised) so they remain INCR/DECR-able atomically.
The side effect is that an int written via Cache::put comes back as a
string on read. The strict ': int' return type on cachedPostCount then
threw a TypeError.

Local dev and CI used the file/array/database drivers respectively,
which serialise everything blindly and preserve the int type, so the
bug never surfaced before deploy.

Fixes:
- Cast the Cache::remember result to (int) — defensive, survives any
  driver-specific behaviour. Documented inline so the cast is not
  later removed as redundant.
- Change config/cache.php default from 'database' to 'redis' so local
  dev matches prod by default and similar driver-specific bugs surface
  before merge instead of after deploy.
- Regression test that seeds the cache with a literal string (mimics
  the production Redis read) and asserts cachedPostCount still returns
  an int.
2026-05-07 14:31:07 -03:00
Paulo Castellano
a829553a24
Merge pull request #20 from trypostit/feature/posthog-tracking
feat: end-to-end PostHog tracking with reactive group metrics
2026-05-07 14:21:27 -03:00
Paulo Castellano
411b9b14ec
Merge branch 'main' into feature/posthog-tracking 2026-05-07 14:20:59 -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
7a25ca0759 fix: gate TrackBilling dispatch in StripeEventListener
The TrackBilling job was being enqueued on every Stripe webhook event
even with POSTHOG_ENABLED=false. handle() short-circuited via the
isEnabled() check, so nothing reached PostHog, but the job still
consumed queue worker cycles (20-300ms each) on accounts where Stripe
fires its frequent customer.subscription.updated events.

Adds the missing isEnabled() check at the dispatch site in
trackPlanChange, plus the two test cases that would have caught this
the first time around (TrackBilling not dispatched when enabled=false,
and not dispatched when api_key is missing). Updates the listener test
beforeEach to opt the suite into the enabled path so the existing
assertDispatched() assertions continue to fire.
2026-05-07 13:35:39 -03:00
Paulo Castellano
0a9ca9a185 fix: serialize plan_slug as string and broaden PostHog enabled tests
Two follow-ups from the final pre-merge review:

1. SyncUser and TrackBilling shipped \$account->plan?->slug, which is a
   PlanSlug backed enum. json_encode renders it correctly on the wire,
   but the queue payload carries an enum instance — anyone introspecting
   the job (Bus::fake, future workers) and string-comparing the slug
   would fail silently. Cast to ->slug->value at the call sites.

2. PostHogServiceTest only covered the api_key=null negative case. The
   primary scenario the gate exists to defend (self-hosted with a stale
   POSTHOG_API_KEY but POSTHOG_ENABLED=false) was not asserted. Added
   four tests covering capture/identify/groupIdentify with enabled=false
   and an api key present, plus a direct truth-table check on
   isEnabled() requiring both flags.
2026-05-07 13:21:58 -03:00
Paulo Castellano
b709c19862 fix: PostHog property keys, deletion idempotency and full enabled gate
Three fixes from a fresh code review:

1. SyncUser identify used 'email' / 'name' instead of the PostHog
   special person properties '\$email' / '\$name'. The frontend already
   used the correct keys; the backend identify (sole source for users
   who sign up but never log in) would have populated only custom
   properties, leaving the built-in person profile email/name blank
   in the PostHog UI.

2. handleSubscriptionDeleted now short-circuits when plan_id is already
   null. Stripe re-delivers webhooks on transient failures, and the
   prior version would dispatch a duplicate 'subscription.cancelled'
   event and re-flush the (already empty) Pennant cache on each retry.

3. useTracking composable called posthog.capture directly, bypassing
   the new enabled gate. While posthog-js queues calls before init
   (so no events leaked over the network in self-hosted mode), the
   buffer grew unbounded and would fire all queued events in bulk if
   init was ever called. Replaced with a gated captureEvent helper
   exported from posthog.ts.

Plus: drop the now-trivial 'updating non-plan fields does not flush
the pennant cache' test (no observer to test against), refresh stale
doc comments referencing the removed SyncUserToPostHog filename, and
add a Bus::assertNotDispatched check to the deletion-idempotency test.
2026-05-07 13:15:36 -03:00
Paulo Castellano
cd28ac4025 feat: explicit POSTHOG_ENABLED gate for self-hosted safety
Self-hosted installs that inherited POSTHOG_API_KEY from an example or
older deploy were still seeing SyncUser/SendEvent jobs run because the
gate was based on the api key alone. Switches the gate to an explicit
'services.posthog.enabled' flag (env: POSTHOG_ENABLED, default false)
and requires both enabled=true AND api_key for tracking to fire.

Backend gating:
- PostHogService::isEnabled() — single static helper used everywhere.
- AppServiceProvider::configurePostHog — skips PostHog::init when off.
- CreateUser::execute — does not enqueue SyncUser when off.
- SyncUser::handle, TrackBilling::handle, SendEvent::handle — early
  return before any DB query so the queue worker does no work.

Frontend gating:
- New VITE_POSTHOG_ENABLED env var mirrored from POSTHOG_ENABLED.
- initializePostHog, syncPostHogContext, capturePageview all gated.

Tests updated to set both flags on the happy path; adds explicit
'CreateUser does not dispatch SyncUser when PostHog is disabled'.

Deploy note: the trypost.it cloud .env must set POSTHOG_ENABLED=true
before this branch is merged or analytics will go dark.
2026-05-07 12:42:35 -03:00
Paulo Castellano
9421717207 feat: require username confirmation when disconnecting a social account
Passes the account's @username as confirmText to ConfirmDeleteModal so
the user has to type it to enable the destructive action. Mirrors the
pattern already used elsewhere in the app for high-impact deletes.
2026-05-07 12:02:28 -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
b91bad7e6f refactor: PostHog review polish
- New BillingEvent enum replaces 'subscription.{created,updated,cancelled}'
  strings across StripeEventListener, TrackBilling and tests.
- SendEvent now takes (method, payload) directly instead of an array of
  single-call shapes — overhead with no batching benefit.
- PostHogService consolidates the 3 api-key short-circuits into shouldSend().
- SyncUser eager-loads currentWorkspace.withCount('socialAccounts') and
  drops the redundant posts_count from the workspace group identify.
- Frontend Usage interface centralised in resources/js/types — was
  duplicated in posthog.ts and useFeatureAccess.ts.
- posthog.init moved out of module-import side-effect into
  initializePostHog() called explicitly from app.ts.
- SyncUserTest cleans up the convoluted assertion that merged
  $job->calls with Queue::pushed().
- Drop tests/Feature/StripeEventListenerTest.php (orphan, fully covered
  by tests/Feature/Listeners/StripeEventListenerTest.php).
- Revert .github/FUNDING.yml to match origin/main.
2026-05-07 10:41:53 -03:00
Paulo Castellano
1df59f09b4 perf: cache account post count for 5 minutes in HasUsage
Posts have no plan-quota gating, so a brief staleness on the count is
acceptable. Avoids the heavy aggregate query on every authenticated
request. Empty-account case skips cache writes entirely.
2026-05-07 10:41:37 -03:00
Paulo Castellano
9bc9e1f93c feat: wire StripeEventListener domain logic for plan swap & cancel
The previous version of `handleSubscriptionUpdated` and
`handleSubscriptionDeleted` were no-ops, so a plan swap or cancellation
on Stripe never reflected in `accounts.plan_id`. Mirrors sendkit's
listener structure and adds full coverage.

Listener:
- `handleSubscriptionCreated` / `handleSubscriptionUpdated` capture the
  previous plan name, resolve the new plan from the subscription items'
  price ids, and update `account.plan_id` if it changed. Pennant
  feature caches are forgotten automatically by `Account::booted()`.
- `handleSubscriptionDeleted` clears `account.plan_id` so the UI and
  authorization checks reflect "no plan" instead of keeping the
  previous one attached. Idempotent.
- Helpers extracted: `resolvePlanFromSubscriptionItems(payload, account)`
  (pure resolver, logs a warning when no plan matches) and
  `trackPlanChange(account, event, previousPlan, payload)` (delegates to
  the queued `TrackBilling` job).
- Restored the `match($type) => handler...` shape with explicit
  protected handler methods for each subscription type.

TrackBilling:
- Adds optional `?string $previousPlan` constructor param. Forwarded as
  `previous_plan` event property so PostHog funnels can tell upgrade
  from downgrade from cancellation.

Tests:
- New: subscription updated/created sync `plan_id` from price ids
  (monthly + yearly), idempotent when price already matches, ignored
  when price ids are unknown, deletion clears plan_id, deletion
  idempotent when already null. Tests override the seeded plans'
  Stripe price ids inline so they don't depend on `.env.testing`
  having `STRIPE_*_MONTHLY/YEARLY` set.
- New: previousPlan is forwarded to `TrackBilling` for updated and
  deleted, and is null for first-activation.
- TrackBilling test: covers the previous_plan property in capture
  payload (both supplied and default-null cases).

Suite: 1438 passed (+11).

Plus a tiny cleanup: replaced the `'PostHog\\SendEvent: ...'`
namespace-look-alike in the SendEvent log warning with the cleaner
`'PostHog SendEvent: ...'`.
2026-05-07 10:10:23 -03:00
Paulo Castellano
3e3c0b4c9d refactor: namespace PostHog jobs + extract billing tracking, add tests
Reorganises PostHog plumbing under `App\Jobs\PostHog` and extracts the
Stripe billing capture out of `StripeEventListener` into its own job.
Adds the missing test coverage that was promised but not delivered in
the previous commit.

Code changes:
- Move `app/Jobs/SendPostHogEvent.php` → `app/Jobs/PostHog/SendEvent.php`
  (low-level dispatcher).
- Move `app/Jobs/SyncUserToPostHog.php` → `app/Jobs/PostHog/SyncUser.php`
  (high-level user/account/workspace sync).
- New `app/Jobs/PostHog/TrackBilling.php` that owns the
  capture('subscription.*') + SyncUser re-dispatch flow. Receives
  account id + event name + payload, runs on the `posthog` queue.
- `StripeEventListener` slims down to a switch table mapping Stripe
  event types to PostHog event names and dispatches `TrackBilling`. No
  more inline tracking logic in the listener.
- `resources/js/posthog.ts` now owns `syncPostHogContext(page)` and
  `capturePageview()`. `resources/js/app.ts` imports them — no behaviour
  inlined in the bootstrap.
- `app/Services/PostHogService.php` and `app/Actions/User/CreateUser.php`
  updated to the new namespaces.

Tests added/updated:
- `tests/Feature/Jobs/PostHog/SyncUserTest.php` — identify/group payload
  shape, account metrics, workspace skip when none, queue assignment,
  no-op without api key.
- `tests/Feature/Jobs/PostHog/TrackBillingTest.php` — capture payload,
  SyncUser re-dispatch, missing-account/owner handling, api key gate.
- `tests/Feature/Jobs/PostHog/SendEventTest.php` — moved from
  `tests/Feature/SendPostHogEventTest.php` and updated to new namespace.
- `tests/Unit/PostHogServiceTest.php` — adds coverage for the
  account-aware capture (auto-attached `\$groups.account`, `account_id`,
  `plan`) and the no-account branch.
- `tests/Feature/Listeners/StripeEventListenerTest.php` — replaces the
  old inline-PostHog assertions with `Bus::fake([TrackBilling::class])`
  and verifies the listener dispatches TrackBilling with the right
  account id + event name for each subscription type, and skips
  non-subscription event types.
- `tests/Feature/Actions/User/CreateUserTest.php` — verifies signup
  dispatches `SyncUser` with the new user id.

Suite: 1427 passed (+20 net new, including the previous round of
metrics-related tests).
2026-05-07 09:41:36 -03:00
Paulo Castellano
e3538df2f0 feat: end-to-end PostHog tracking with reactive group metrics
Wire PostHog identify + dual-group context across the stack so events
land on the right person and account/workspace groups, with counts kept
fresh by Inertia navigations rather than per-domain triggers.

- New `app/Jobs/SyncUserToPostHog.php` (queue `posthog`): centralised
  high-level sync — identifies the user and group-identifies their
  account + current workspace using `$account->usage()` so the metrics
  reuse the same source of truth Inertia ships in shared props.
- `app/Actions/User/CreateUser.php`: dispatches `SyncUserToPostHog` on
  signup instead of calling `PostHogService` inline. Keeps the action
  fast and routes everything through the queue.
- `app/Listeners/StripeEventListener.php`: webhook now captures
  `subscription.created`/`updated`/`cancelled` against the account
  owner profile (with `account` group auto-attached) and re-dispatches
  `SyncUserToPostHog` so plan/has_active_subscription/is_on_trial
  refresh after Stripe state changes.
- `app/Services/PostHogService.php`: `capture()` accepts an optional
  `Account` that auto-attaches `$groups.account`, `account_id`, and
  `plan` properties. Each public method short-circuits when
  `POSTHOG_API_KEY` is unset so self-hosted installs are unaffected.
- `app/Models/Traits/HasUsage.php`: adds `postCount` to the usage
  shape (combined `withCount(['socialAccounts','posts'])` query) so
  posts count is part of the same payload Inertia already ships.
- `config/horizon.php`: adds `posthog` to `supervisor-1` queues so the
  queued PostHog jobs actually drain in production.
- `resources/js/app.ts`: extracts `syncPostHogContext(page)` and calls
  it on boot AND on every Inertia navigation, reading the fresh
  `usage` props. This:
  - Refreshes account group counts (workspaces, social accounts,
    posts, members, credits) without per-domain triggers.
  - Resolves the workspace-switch case where `setup()` does not
    re-run but `navigate` fires with the new `auth.currentWorkspace`.
  - Captures the initial `$pageview` so the first page of a session
    is no longer dropped.
- `resources/js/components/UserMenuContent.vue`: `posthog.reset()` on
  logout so a follow-up login on the same browser doesn't keep events
  attributed to the previous user.
- `resources/js/composables/useFeatureAccess.ts`: TS `Usage`
  interface gains `postCount`.
- `tests/Feature/Models/HasUsageTraitTest.php`: updated for the new
  usage shape. Full suite: 1407 passed.

Hierarchy aligned with the domain model: person = User,
group `account` = billing/plan parent, group `workspace` =
collaboration child (carries `account_id` for drill-down).
2026-05-07 09:28:02 -03:00