Commit graph

473 commits

Author SHA1 Message Date
Paulo Castellano
9845d15db3 refactor(media): MediaUploadResource + ws→workspace_id + cleanup 2026-05-15 16:48:09 -03:00
Paulo Castellano
98b3a9ffb7 test(mcp): end-to-end media upload flow 2026-05-15 16:37:40 -03:00
Paulo Castellano
41a704dc6a fix(mcp): use morph alias for mediable_type to match production storage 2026-05-15 16:34:25 -03:00
Paulo Castellano
dd1056a413 feat(mcp): AttachMediaFromUploadTool attaches uploaded Media by token 2026-05-15 16:29:05 -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
7971571dc3 feat(mcp): RequestMediaUploadTool issues signed upload URLs 2026-05-15 16:19:03 -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
5408ca7e27 style(media): add void return on migration closures, drop noop ->after 2026-05-15 16:06:54 -03:00
Paulo Castellano
99f1f7ed84 feat(media): add upload_token column for MCP signed uploads 2026-05-15 16:02:46 -03:00
Paulo Castellano
626da2c4dd
Merge pull request #40 from trypostit/fix/remove-redundant-publish-toast
fix(posts): drop redundant 'publishing' toast on publish
2026-05-15 12:30:06 -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
4cd0b642e1
Merge pull request #39 from trypostit/fix/pinterest-board-picker
fix(pinterest): restore board picker + require board_id in validation
2026-05-15 12:07:13 -03:00
Paulo Castellano
1f5a16ef2c refactor(pinterest): reuse InputError, extract PinterestBoard type
- PinterestSettings: replace inline error markup with the shared
  InputError component (matches the rest of the form patterns)
- Extract PinterestBoard type to @/types and use it across
  PinterestSettings, ScheduleTab, PostEditorSidebar, Edit — drops
  the duplicated inline {id, name} shape from 4 call sites
- Block publish/schedule when Pinterest is selected but no board is
  picked (mirrors tiktokComplianceValid). Tooltip surfaces the same
  i18n key the backend uses, so frontend + backend speak the same
  language
2026-05-15 12:04:01 -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
a9e8efffea
Merge pull request #38 from trypostit/feat/trial-without-card
feat(signup): no-card 7-day trial on Starter plan
2026-05-14 20:26:26 -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
0b60fba277 chore: drop redundant fallback on config('cashier.trial_days') 2026-05-14 20:00:24 -03:00
Paulo Castellano
d71df05291 refactor: inline plan_id and trial_ends_at in CreateUser + drop docblock on activeTrialEndsAt 2026-05-14 19:59:50 -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
368176cccb
Merge pull request #36 from trypostit/fix/linkedin-page-scopes
fix(linkedin-page): persist OAuth scopes through the page-picker flow
2026-05-14 11:49:20 -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
3845737857
Merge pull request #35 from trypostit/fix/linkedin-scopes-parsing
fix(linkedin,pinterest): split CSV/space-joined OAuth scopes before saving
2026-05-14 11:05:51 -03:00
Paulo Castellano
62e5d6da57 test(linkedin-sync): cover same-workspace cross-admin isolation
Adds an explicit test asserting that when a workspace has multiple
LinkedIn users (e.g., the owner plus a client teammate), syncing
tokens from one user's accounts never touches accounts admin'd by a
different LinkedIn user — even though all live in the same workspace.

The behavior is already correct by virtue of the
`admin_user_id` / `platform_user_id` match in the synchronizer's
query, but the property is now part of the test contract instead of
emergent.
2026-05-14 11:04:04 -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
f966b27349
Merge pull request #34 from trypostit/feat/posts-label-filter
feat(posts): multi-select label filter on the posts list
2026-05-14 10:03:02 -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
154a202b84
Merge pull request #32 from trypostit/fix/facebook-reels-upload
fix(facebook): use upload_url + file_url for reel transfer phase
2026-05-12 20:51:34 -03:00
Paulo Castellano
ee18b489e6 refactor(facebook): cleanup publishReel after review
Five small fixes scoped to the publishReel method:

1. Replace generic \Exception on media download failure with a typed
   FacebookPublishException(ServerError). The generic exception was
   landing in the \Throwable catch in PublishToSocialPlatform with
   category 'unknown', defeating the whole point of the social
   exception hierarchy.

2. Replace handleApiError($startResponse) with a direct
   FacebookPublishException throw when video_id/upload_url are missing.
   The previous code passed a successful HTTP response into a method
   built for error responses — fromApiResponse would fall into the
   default arm and surface 'An unknown Facebook error occurred.'
   ironically reintroducing the same bad UX we just spent the day
   fixing.

3. Drop the four redundant Log::error calls before handleApiError.
   FacebookPublishException::fromApiResponse already pulls the FB
   error code/subcode/message into platformErrorCode + userMessage,
   and the downstream catch in PublishToSocialPlatform::handle logs
   the exception anyway (Nightwatch picks that up). Same pattern as
   the X cleanup in PR #29.

4. Stream the upload body via fopen() resource instead of
   file_get_contents(). Eliminates loading the whole video into memory
   for large reels.

5. Replace \@unlink with unlink + Log::warning. Surfaces temp-file
   cleanup failures instead of silently leaking files.

Tests:
- Strengthened the missing-upload_url test to assert the exception
  message and class.
- Added a typed-exception test for the media-download failure path
  (would have caught the regression where we used a generic
  \Exception).
- Full suite green: 1505 passed, 2 skipped, 0 failed.
2026-05-12 20:43:32 -03:00
Paulo Castellano
1d5d6ec063 fix(facebook): switch reel transfer to binary upload with Offset/file_size headers
Previous attempt at the hosted-file flow (`file_url` in JSON body) hit
the rupload.facebook.com validator with HTTP 400:

  'HeaderValuePredicate: Header Offset not convertable to unsigned long'

Facebook's rupload endpoint requires the `Offset` and `file_size` headers
regardless of whether the upload is local or hosted-file. The docs
describe the hosted-file path without them, but in practice rupload
rejects requests that lack them.

Switching to the well-documented local-file flow:
- Download the media to a temp file via Http sink (already a pattern
  used in XPublisher for media downloads).
- POST raw bytes to upload_url with three headers:
  - Authorization: OAuth {token}
  - Offset: 0
  - file_size: {actual bytes}
- Use mime_type from the media item as the Content-Type.
- Always cleanup the temp file in a finally block.

Tests updated to fake the media-download GET (returning bytes that the
publisher then re-uploads) and to assert on the Offset/file_size/
Authorization headers being present and correct. The existing
"cleans up temp files after reel upload" test now actually exercises
its assertion since this code path creates temp files again.

Full suite: 1504 passed, 2 skipped, 0 failed.
2026-05-12 20:35:54 -03:00
Paulo Castellano
c379c4c14f fix(facebook): use upload_url + file_url for reel transfer phase
Production was failing every Facebook reel publish with the cryptic
'Video Upload Is Missing' / error_subcode 1363130. Root cause: our
'transfer' phase was making up its own API contract that doesn't exist.

The Reels publishing API (per Meta docs) is a 3-step flow:

  1. start → POST /{page_id}/video_reels {upload_phase=start}
             returns {video_id, upload_url}
  2. transfer → POST {upload_url} on rupload.facebook.com
                Header: Authorization: OAuth {token}
                Body (JSON): {"file_url": "https://..."}
  3. finish → POST /{page_id}/video_reels {upload_phase=finish, ...}

Our code was doing:

  2 (broken) → POST /{video_id} on graph endpoint
               Body: {video_file_chunk: '<some URL>', access_token: ...}

`video_file_chunk` is not a real parameter; the graph endpoint accepted
the request (returning 200) but nothing was actually uploaded, so the
finish phase reported 'Video was not uploaded'.

Changes:
- Capture upload_url from the start response and use it for transfer.
- POST to that upload_url with file_url in JSON body and
  'Authorization: OAuth ...' header (the format docs.facebook.com
  documents for the hosted-file flow).
- Bail with handleApiError if the start response is missing video_id
  or upload_url so we don't silently no-op like before.

Tests:
- Existing 'can publish reel' test updated to include upload_url in the
  start mock and assert the transfer call actually POSTs to rupload
  with file_url + OAuth header (would have caught this bug before
  shipping).
- New 'fails reel publish when start does not return upload_url' test
  for the safety bail-out.
- Pre-existing 'cleans up temp files after reel upload' test updated
  to match the new mock pattern.

Full suite green: 1504 passed, 2 skipped, 0 failed.
2026-05-12 20:32:20 -03:00
Paulo Castellano
1bc1ec9ef5
Merge pull request #31 from trypostit/fix/x-chunked-upload-413
fix(x): drop chunked upload chunk size from 5MB to 1MB
2026-05-12 20:03:49 -03:00
Paulo Castellano
7d82cc3f12 fix(x): drop chunked upload chunk size from 5MB to 1MB
Production was returning HTTP 413 with empty body on the first APPEND
segment of every video upload to X v2 — surfacing to users as 'An
unknown X error occurred.'

Empty-body 413 is the classic signature of an edge/CDN rejection: the
X gateway is denying the request before X's application code sees it.
The X v2 reference docs say 'max chunk size: 5MB', but every canonical
reference uses 1MB:
- X's official Python quickstart: `chunk_size = 1024 * 1024`
- X's official JavaScript quickstart: `const chunkSize = 1024 * 1024`
- twitter-api-v2 (the most-used Node SDK, used by Postiz et al.):
  `chunkSize: number = 1024 * 1024`

5MB plus multipart-form overhead apparently exceeds an undocumented
edge limit. 1MB is the empirically safe size everyone converges on.

Changes:
- XPublisher chunked APPEND now uses 1MB chunks. An 8MB video uploads
  as 8 segments instead of 2; more roundtrips but actually succeeds.
- Set explicit Content-Type on each chunk attach (matches the simple
  upload path in the same file).
- XPublishException::fromApiResponse maps HTTP 413 to
  ErrorCategory::MediaFormat with the message 'Media chunk rejected
  by X (payload too large).' so we don't surface 413 as 'unknown' if
  it ever recurs.

Test: unit test covering the 413→MediaFormat mapping. Full suite green
(1503 passed, 2 skipped).
2026-05-12 20:02:23 -03:00
Paulo Castellano
6c2c555bd0
Merge pull request #30 from trypostit/fix/proactive-token-refresh
fix(social): proactive token refresh actually refreshes (not just verifies)
2026-05-12 19:44:03 -03:00
Paulo Castellano
3ba47ad02a fix(social): proactive token refresh actually refreshes (not just verifies)
Three orthogonal fixes that together close the gap where social tokens
were silently aging out without ever being refreshed, then dying at the
provider when the refresh_token also got revoked.

The original failure mode: a user's X token expired because the hourly
proactive-refresh cron's smart `verify()` skip-logic kept saying 'token
still works, no need to refresh', and once the token actually expired,
the cron's WHERE clause excluded it from future runs. By the time anyone
noticed, the refresh_token at X was also gone.

(C) ConnectionVerifier: rename private `refreshTokenIfNeeded` →
    public `refreshToken`. Callers that want the smart 'try
    access_token first' behavior keep using `verify()`. Callers that
    want a proactive refresh (the cron) call `refreshToken` directly.

(B) RefreshExpiringTokens command: drop the
    `where('token_expires_at', '>', now())` filter. Already-expired
    tokens now get a last-chance refresh attempt before the
    refresh_token also dies at the provider. Status filter
    (`Connected`) still excludes accounts already marked TokenExpired.

(D) RefreshSocialToken job: switch from `verify()` to
    `refreshToken()`, and on `TokenExpiredException` call
    `markAsTokenExpired` so the user is notified immediately. The lock
    + transition detection in markAsTokenExpired prevents notification
    spam if subsequent cron passes also fail.

Tests:
- 3 new tests for RefreshSocialToken (calls refreshToken not verify,
  marks TokenExpired on TokenExpiredException, logs warning on other
  errors)
- Updated RefreshExpiringTokens test to assert already-expired tokens
  are now dispatched (was previously asserted as 'should NOT')
2026-05-12 19:36:35 -03:00
Paulo Castellano
82cf8aeaa8
Merge pull request #29 from trypostit/fix/social-token-expired-handling
fix(social): unify token-expired handling across all publishers
2026-05-12 19:18:26 -03:00
Paulo Castellano
a741a452af test(social-account): cover markAsDisconnected, notify flag, disconnected_at preservation, and i18n placeholder substitution
Fills gaps surfaced in the code review:

- 3 new `markAsDisconnected` tests (mirroring the existing TokenExpired
  trio). Previously the method had zero coverage despite this PR
  changing its lock key.
- 1 test confirming `markAsTokenExpired($msg, notify: false)` skips
  notification dispatch (used by VerifyWorkspaceConnections batch path).
- 1 test confirming `disconnected_at` is preserved when already set
  (the `?? now()` branch).
- 2 end-to-end tests (one per method) that DO NOT fake the queue, so
  `SendNotification::handle()` runs synchronously. Asserts the
  Notification row is actually created in the DB with the correct
  i18n-substituted title/body, that NotificationCreated event is
  dispatched, and that AccountDisconnected mail is queued. Catches
  bugs where the i18n placeholder keys (`:platform`, `:account`)
  silently fail to substitute.
2026-05-12 19:17:16 -03:00
Paulo Castellano
a37eb6ae9e fix(social): unify status lock + i18n notification strings
Two follow-up fixes from the code review:

1. **Unified lock key for markAsDisconnected / markAsTokenExpired.**
   Both methods now use `social_account_status:{id}` instead of
   different keys. Prevents the race where `markAsDisconnected` and
   `markAsTokenExpired` could run concurrently on the same account
   (publish-time vs verify-batch-time), causing overlapping updates and
   duplicate notifications.

2. **i18n for notification title/body in markAsTokenExpired and
   markAsDisconnected.** Strings were previously hardcoded in English.
   Added `notifications.account_disconnected.{title,body}` and
   `notifications.account_token_expired.{title,body}` in en, pt-BR, es.
   Follows the project convention (e.g. `Mail/PostPublished`) of
   concatenating the `@` prefix in PHP before passing the username to
   the translation placeholder, instead of putting `@:account` in the
   lang file.
2026-05-12 19:11:23 -03:00
Paulo Castellano
d349e499b5 test: move publisher tests from Unit to Feature
These tests use Http::fake, model factories, and DB — by Laravel/Pest
convention that's a feature test, not a unit test. Moving them to the
Feature suite to match the convention.

No code changes. Tests still pass: 1493 passed, 2 skipped, 0 failed.
2026-05-12 19:02:51 -03:00
Paulo Castellano
d5d3eae839 fix(social): standardize refresh-token rejection across all publishers
Extends the X-only fix to LinkedIn, LinkedInPage, Pinterest, Threads, and
TikTok. Each publisher's `refreshToken` now throws `TokenExpiredException`
directly when the OAuth refresh endpoint rejects the refresh_token, instead
of routing through `handleApiError` -> `<Platform>PublishException::fromApiResponse`.

Why: `fromApiResponse` is designed for the publish API response shape and
only converts certain status codes to `TokenExpiredException` (e.g., 401).
OAuth refresh failures typically return 400 with an `error`/
`error_description` (or platform variant) body and were falling into the
generic 'Unknown' bucket, masking the real cause and skipping the proper
`markAsTokenExpired` + user-notification flow.

Dropped the redundant `Log::error` before each `throw`: the downstream
catch in `PublishToSocialPlatform::handle` already logs the exception
(captured by Nightwatch), and the rich context now lives on the exception
itself (`message` = provider's `error_description` / `error.message`,
`platformErrorCode` = HTTP status).

Tests: one regression-style test per publisher confirming refresh rejection
becomes `TokenExpiredException` instead of a generic publish exception.
2026-05-12 18:56:52 -03:00
Paulo Castellano
620d23187e fix(social): handle TokenExpired status fail-fast and notify user
Three related fixes for the failure mode where a scheduled post errors out
as 'An unknown X error occurred.' when a social account's refresh_token
was already invalidated by the provider:

1. **PublishToSocialPlatform**: fail-fast when account status is
   `TokenExpired`. Previously the job tried to publish, the publisher
   internally tried to refresh, the provider rejected the rotated
   refresh_token, and the failure surfaced as a generic 'unknown' error
   instead of a clear 'reconnect your account' signal.

2. **XPublisher::refreshToken**: when the OAuth endpoint rejects the
   refresh_token (typically because it was rotated/revoked at X), log the
   raw response and throw `TokenExpiredException` instead of falling
   through to `XPublishException::fromApiResponse` which expects the
   tweet-API response shape (`type`/`title`/`detail`) and treats
   OAuth-style responses (`error`/`error_description`) as 'Unknown'.

3. **SocialAccount::markAsTokenExpired**: dispatch an in-app + email
   notification (`Type::AccountDisconnected`) when an account
   transitions from `Connected` → `TokenExpired`, mirroring the
   existing pattern in `markAsDisconnected`. Wrapped in a lock to
   prevent duplicate notifications on concurrent transitions. Accepts an
   optional `notify: false` so the batch verifier
   (`VerifyWorkspaceConnections`) can suppress per-account
   notifications and rely on its summary email.
2026-05-12 18:46:06 -03:00
Paulo Castellano
3a3bdec7f5
Merge pull request #28 from trypostit/feat/google-ads-conversion-tracking
feat(tracking): push Google Ads conversion data to dataLayer on purchase
2026-05-12 15:27:07 -03:00