Commit graph

18 commits

Author SHA1 Message Date
Paulo Castellano
9f4406e304
fix: replace hardcoded platform URLs in ConnectionVerifierTest with config() (#258)
Closes #257
2026-08-09 11:21:54 -03:00
Paulo Castellano
a147c7414b
feat: proactive connection check for at-risk posts + SocialAccount name centralization (#256)
* chore: gitignore .superpowers/ scratch workspace

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

* feat: add connection_warning_sent_at to post_platforms

* feat: add PostAtRisk notification type and translations

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

* feat: add PostAtRisk mailable and email template

* feat: add VerifyUpcomingPostConnections job

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

From a deep review pass on the whole branch:

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

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

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

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

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

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

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

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

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

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

Three correctness issues found by review, fixed after discussion:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Centralize avatar-initial extraction via getInitials()

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

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

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

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

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

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

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

* Add social icons and copyright to email footers

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

* Standardize connection-verify error classification across all 13 platforms

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

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

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

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

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

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

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

* Clarify "commit" wording in claim-transaction comment

Reads ambiguously as a git commit on a PR diff; it means the DB
transaction commit.
2026-08-09 11:10:39 -03:00
Paulo Castellano
1adef7787c
fix: detect dead Threads/Instagram/Facebook tokens reported under non-190 codes (#254)
* fix: detect dead Threads/Instagram/Facebook tokens reported under non-190 codes

verifyThreads/verifyInstagram/verifyFacebook only threw TokenExpiredException
for Meta error code 190, silently returning false for every other rejection
(e.g. code 100 "The requested resource does not exist"). The hourly
VerifyWorkspaceConnections check never saw that false, so a genuinely dead
token went unflagged — no reconnect email — until the real scheduled post
tried to publish and failed with the same raw error (#230).

GraphError::isTransient() now isolates the known rate-limit/transient codes
(1, 2, 4, 17); everything else on a failed verify/refresh is a confirmed
rejection and raises TokenExpiredException, while transient/5xx/429 raises
PlatformUnavailableException so the account isn't disconnected on a throttle.

Also drops the unused $errorType variable from the three *PublishException
classes.

* fix: treat unparseable Meta failure bodies as transient, drop dead code

Code review on #254 found two issues in the original fix:

- The inverted classifier (`! GraphError::isTransient($body)`) treated a
  response body that fails to parse as JSON (WAF block page, truncated
  response, gateway hiccup) as a confirmed dead token, since isTransient()
  returns false for a body it can't recognize. That flipped a null/unparseable
  body from "retry later" (PlatformUnavailableException, the pre-fix behavior)
  to "disconnect now" (TokenExpiredException) for both the Threads/Instagram
  refresh classifiers and the verify path's classifyMetaVerifyFailure. Fixed
  by treating a null body as transient at both call sites — we have no
  confirmed rejection from Meta to act on.
- GraphError::indicatesInvalidToken() had no remaining production callers
  after the refresh classifiers switched to isTransient() — removed it and
  its tests instead of leaving dead code behind.

* test: symmetric Facebook/Instagram coverage for the shared verify classifier

verifyInstagram/verifyFacebook/verifyThreads all delegate to the same
classifyMetaVerifyFailure(), so the non-190 dead-token, rate-limit,
5xx, and non-JSON-body cases were only exercised end-to-end for
Threads. Adds the missing Facebook (rate-limit, 5xx, non-JSON) and
Instagram (non-190 dead token, 5xx) cases so each platform has direct
proof, not just shared-code inference.

* fix: recognize Business Use Case (BUC) rate-limit codes for Page-token accounts

Verified the transient-code list against Meta's official docs. Confirmed:
codes 1, 2, 4, 17, 190 match what's documented at
developers.facebook.com/docs/graph-api/guides/error-handling/. But Meta runs
a SECOND, separately-coded rate-limit system (Business Use Case / BUC) for
Page and system-user tokens — which is exactly what our Facebook and
InstagramFacebook accounts use. BUC rejections come back as a plain HTTP 400
(not 429) with codes in the 80000 range (80001 Pages API, 80005 Instagram
Platform), which GraphError::isTransient() didn't recognize — meaning a
throttled Facebook/InstagramFacebook Page token would have been misclassified
as a confirmed dead token and disconnected.

- Added 80001/80005 to GraphError::TRANSIENT_CODES, with sources.
- Added GraphError::isTransientFailure(Response) to fold the status-based
  checks (5xx, 429) and body-based checks together into one documented
  method, replacing the ad-hoc multi-condition `if` that lived inline in
  ConnectionVerifier::classifyMetaVerifyFailure().
- isTransient() now treats a null (unparseable) body as transient directly,
  so the refresh-path classifiers no longer need a separate null guard.
- Documented the full code table, sources, and per-platform token-type
  notes (Page token vs. user token, which rate-limit system applies to
  which platform) in GraphError's class docblock and in CLAUDE.md, so
  future changes here start from verified sources instead of guessing.

* refactor: move Meta verify-failure classification into GraphError

classifyMetaVerifyFailure() lived in ConnectionVerifier but never touched
$this, SocialAccount, or the cache lock — it was a pure (Response, label) ->
Exception translation, same shape as what TokenRefreshClient already owns
for the refresh side. Keeping it in ConnectionVerifier broke that symmetry
and split Meta error interpretation across two classes instead of the one
(GraphError) whose docblock already says that's its job.

Moved as GraphError::classifyVerifyFailure(), dropped the now-unused
Response import from ConnectionVerifier, and added direct unit tests for
the new public method alongside the existing ConnectionVerifierTest
coverage that exercises it through verify().

* fix: correct Instagram Platform BUC code from 80005 to 80002

My earlier WebFetch of Meta's rate-limiting page mis-parsed the BUC code
table and mapped 80005 to Instagram Platform. It's actually Lead Generation
(Marketing API, which this app never calls) — Instagram Platform is 80002.
Verified against a raw, unsummarized reproduction of the same official page
(developers.facebook.com/docs/graph-api/overview/rate-limiting/) plus
independent third-party corroboration, both pointing to 80002.

Also closes a test-coverage gap flagged in review: GraphError::isTransient()
now intentionally treats a parseable body with no "error" key (e.g.
{"data": {...}}) as a confirmed rejection, not transient — a real behavior
change from the pre-#254 code, which silently ignored that shape. Added
explicit unit + integration coverage for it so the decision is asserted,
not implicit.
2026-08-08 13:24:10 -03:00
Paulo Castellano
ba8068f517 Recover from a lost-rotation race on the post-refresh verify too
refreshThenVerify only guarded refreshToken(); the verify that followed was
outside the try, so the sub-commit window where a lock-skipped refresh reloads a
not-yet-persisted token surfaced as a 401 there and falsely disconnected a
still-usable single-use-refresh_token account (X/LinkedIn). The refresh and the
verify that follows it now share one recovery: on a TokenExpiredException,
reload and, if a concurrent refresh has since persisted a fresh access_token,
verify with it instead of giving up.
2026-07-03 11:39:48 -03:00
Paulo Castellano
83ba72ddc6 Stop Meta rate-limit errors from disconnecting Instagram/Threads tokens
TokenRefreshClient classified every non-5xx/429 refresh failure as a dead
token. Meta returns rate-limit (code 4/17) and transient (code 1/2) errors as
HTTP 4xx with type OAuthException, so a throttled proactive refresh was
disconnecting still-valid Instagram/Threads tokens — and the wider 24h/15-min
refresh cadence raised the odds of hitting it.

Classification now keys on error code 190 (the signal the publish exceptions
already use): only a genuine 190 disconnects; every other Meta 4xx is treated as
transient (PlatformUnavailable) and retried next cycle. The same over-broad
type-based check in verifyInstagram/verifyFacebook/verifyThreads is replaced
with the shared Meta\GraphError helper so verify and refresh agree.
2026-07-03 11:07:54 -03:00
Paulo Castellano
545a779848 Never leave Instagram/Threads with a null token expiry
A null token_expires_at drops an account from every refresh path (the
cron's whereNotNull filter and the is_token_expired / is_token_expiring_soon
checks all treat null as "nothing to do"), so the token silently lapses.
Threads could persist null two ways: the long-lived exchange failing at
connect (kept the ~1h short-lived token) — now fails the connect instead;
and a refresh response omitting expires_in — now defaults to 60 days for
both Instagram and Threads, matching the X refresh convention.
2026-07-03 10:33:26 -03:00
Paulo Castellano
2f4b974130 Fix X token chain breaking from over-rotation
X OAuth2 refresh tokens are single-use: each refresh rotates the pair and
invalidates the previous refresh_token, and reusing a rotated one kills the
whole family. Three things made this fragile and disconnected accounts far
more often than necessary:

- The proactive refresh job called refreshToken() directly, bypassing the
  access-token-first guard in verify() and rotating on every run.
- RefreshExpiringTokens used a 2h window on an hourly schedule — equal to the
  2h access-token lifetime — so every X account was rotated every hour even
  while its token was still valid.
- A single 4xx refresh failure disconnected the account without checking
  whether a concurrent refresh had already persisted a working token.

Changes:
- RefreshSocialToken now routes through verify() (access-token-first), so it
  only rotates when the access_token is actually invalid.
- Shrink the proactive window to 30m and run the command every 15m, so the
  window still covers the run interval but rotation happens near real expiry.
- verify() tolerates the lost-rotation race: on a 4xx refresh, reload and
  verify with a concurrently-refreshed token before marking TokenExpired.

Refs #126
2026-07-02 20:32:55 -03:00
Paulo Castellano
12a1baa757 refactor(social): address PR review follow-ups
- DeleteWorkspace now reassigns affected users to another workspace they
  belong to (instead of nulling current_workspace_id), so deleting the
  current workspace while owning others no longer bounces to "create".
- Remove the dead LINKEDIN_PAGE_CLIENT_REDIRECT env (the unified flow uses
  a single callback; config no longer reads a page redirect).
- useOAuthPopup uses the Wayfinder connect helpers per platform instead of
  hardcoding /connect/{platform}.
- Unify the Inertia\Response alias in Mastodon/Bluesky controllers to
  InertiaResponse, matching the other connect controllers.
- Add tests: workspace reassignment on delete, and independent refresh of
  a linkedin row vs its linkedin-page sibling.
2026-06-25 15:01:23 -03:00
Paulo Castellano
c2dd4515b2 feat(channels): add Discord as a social channel
Connect a Discord server via OAuth (bot authorization) and schedule/publish
messages to its channels, with mentions and rich embeds.

- Connect: custom Socialite Discord provider (bot scope) maps the authorized
  guild to a SocialAccount; throws if no server was authorized.
- Publish: DiscordPublisher posts via the global bot token, validates the chosen
  channel belongs to the connected guild (anti cross-guild), optimizes media,
  builds allowed_mentions only from explicit mention chips (no accidental pings),
  and renders rich embeds.
- Compose: per-post channel picker (live lookup), mention autocomplete and an
  embed editor, gated by a required-channel compliance rule; Discord post preview.
- Enum/config/content-type wiring, ConnectionVerifier health check, throttled
  lookup endpoints, i18n (en/es/pt-BR), and tests.

Operators must create a Discord application and set DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN and DISCORD_CLIENT_REDIRECT.
2026-06-16 14:44:00 -03:00
Paulo Castellano
04975020e4 test(social): tests pull OAuth URLs from config
Same rule applied to production code in earlier commits now applies to
tests: Http::fake patterns and assertions read from
config('trypost.platforms.*.oauth_api' / '.api' / '.default_service')
instead of hardcoded strings. Hardcoded URLs in tests drift silently
when the config changes.

Also documents the rule in CLAUDE.md under "External Service URLs" so
new code (and tests) start in the right place — only the host comes
from config, path/RPC segments stay inline next to the call.
2026-05-19 09:37:16 -03:00
Paulo Castellano
f975171a9a test(social): close coverage gaps for TokenRedactor and 429 handling
- TokenRedactorTest (7 unit tests): all four regex patterns, multiple
  secrets in one body, null input, and the no-op pass-through case.
  Guards against silent regression of the redaction regexes (which
  previously drifted across three duplicated copies).
- ConnectionVerifierTest: HTTP 429 during refresh raises
  PlatformUnavailableException, not TokenExpiredException. Locks in
  the rate-limit-as-transient behavior added when consolidating the
  refresh logic.
2026-05-19 09:34:04 -03:00
Paulo Castellano
d5e28e3d02 fix(social): move Mastodon default instance to config + cleanup
Review follow-ups:

- verifyMastodon was the last hardcoded host left after the PR moved
  LinkedIn/YouTube/Bluesky to config. Adds trypost.platforms.mastodon
  .default_instance (env MASTODON_DEFAULT_INSTANCE) and reads from it.
- refreshToken() docblock now declares @throws PlatformUnavailableException
  (the whole point of the PR was missing from its contract).
- Strip the new explanatory comments inside catch blocks and tests —
  rationale lives in the commit / PR, not inline. The two comments
  inside empty `catch (TokenExpiredException) {}` blocks stay because
  there the comment is the only thing telling the reader why the
  exception is swallowed.
2026-05-19 08:27:46 -03:00
Paulo Castellano
6f96d67dbc fix(social): distinguish platform-down from token-expired
When a provider's API was down (5xx, timeout, DNS), the hourly
RefreshSocialToken job and daily VerifyWorkspaceConnections job were
treating it as "token revoked" and emailing the user to reconnect.
Bluesky going offline triggered false-positive disconnect notifications
because Bluesky access tokens are short-lived (2h) so every hourly
refresh failed during the outage.

- New PlatformUnavailableException: API unreachable / 5xx, transient.
  TokenExpiredException stays for 4xx (token is provably bad).
- New TokenRefreshClient: normalizes failure semantics for OAuth
  refresh HTTP calls across all providers. Takes a Platform enum so
  typos fail at compile time and the user-facing label comes from
  one source.
- ConnectionVerifier: all 8 refresh*Token methods route through the
  new client. Hardcoded OAuth URLs (LinkedIn, YouTube) and Bluesky's
  default PDS host moved into config/trypost.php alongside the
  existing per-platform entries.
- RefreshSocialToken job: PlatformUnavailableException → log warning
  and stop. Do NOT markAsTokenExpired, do NOT notify the user. Next
  scheduled tick retries.
- VerifyWorkspaceConnections job: PlatformUnavailableException from
  the inner refresh propagates and is treated as a transient skip.
2026-05-19 08:16:43 -03:00
Paulo Castellano
595510812c refactor: implement lazy token refreshing and persist Mastodon scopes 2026-05-03 21:58:25 -03:00
Paulo Castellano
dafdd5da43 feat: media gallery picker, custom emoji picker, preview tabs, real platform logos
- gallery: extract /assets tabs (uploads, Unsplash, Giphy) into shared
  GalleryBrowser used by both /assets and a new MediaPickerDialog inside the
  post editor; add JSON search endpoint for workspace assets with tests
- emoji: replace broken emoji-picker-element web component with a custom
  EmojiPicker (full Unicode set, search, categories, recently-used,
  light/dark, i18n)
- preview tab: platform selector pills, variant tabs (data-driven from
  content_types map) so the user can switch Feed/Reel/Story etc. and have
  it autosave through the same handler ScheduleTab uses
- platform logos: shared usePlatformLogo composable (logo + label + content
  types); replaces inline maps across 5 components, fixes
  instagram-facebook falling back to default.png
- tooltips: hover details (display_name · @username + platform label) on
  platform avatars across editor, posts list and calendar
- settings cards: show ` · @username` in the title bar so multiple accounts
  on the same network are distinguishable
- routes: drop the throttle:6,1 group middleware on social connect routes
  (was 429ing legitimate OAuth retries) and rely on the default limiter
2026-05-01 14:53:49 -03:00
Paulo Castellano
9f3b8e547a fix: overhaul social publishing — validation, uploads, token refresh
- Fix UpdatePostRequest missing content_type, synced, meta fields
  (content_type was silently dropped, causing Instagram Reels to post as Feed)
- Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation
- Fix syntax errors in all publishers ($media->isVideo() missing variable)
- Fix Instagram Feed with single video calling publishSingleImage instead of publishReel
- Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API
- Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload
- Fix all publishers using file_get_contents for large videos (memory overflow)
  — X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream
- Fix Media::isVideo/isImage to use mime_type instead of extension
- Fix Threads not saving refresh_token (was null, now saves access_token)
- Add Instagram token refresh to publisher and ConnectionVerifier
- Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads),
  timeout 60→600s, added failed() method for cleanup
- Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s
- Increase upload limit 500MB→1GB
- Add mastodon to getDefaultContentType in Edit.vue
2026-03-31 19:25:19 -03:00
Paulo Castellano
56b8c92e72 refactor: settings redesign, Spanish translations, language system, strict_types
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals

Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController

Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props

All 710 tests passing.
2026-03-30 00:20:43 -03:00
Paulo Castellano
a976e11263 fix: refresh tokens before verifying social connections and fix dark mode
- ConnectionVerifier now refreshes expired/expiring tokens before verification
- Prevents false disconnection emails when tokens are expired but refreshable
- Added refresh methods for LinkedIn, X, Bluesky, YouTube, TikTok, Pinterest, Threads
- Fixed dark mode for calendar post items (status colors)
- Fixed dark mode for platform tabs in post editor
- Added tests for token refresh behavior
2026-01-26 10:06:54 -03:00