Commit graph

7 commits

Author SHA1 Message Date
Paulo Castellano
96ec995fcd
Remove the X API reads that buy nothing (#299)
* Stop paying X for token checks the refresh already proves

RefreshSocialToken verified rotating-refresh platforms instead of
refreshing them. On a still-valid token verify() only called the platform's
verify endpoint and left token_expires_at untouched, so the account stayed
inside RefreshExpiringTokens' 30-minute window and was re-read every 15
minutes until the token actually died.

On X that endpoint is GET /2/users/me, billed as a "User: Read" ($0.010 per
resource under X's pay-per-usage pricing). Simulating 24h of the scheduler
against one connected X account: 36 billed reads per day, 24 of which
renewed nothing, plus 180 minutes per day sitting on an expired token
between expiry and the next tick.

Refresh the token outright instead. A provider that hands back a fresh token
has already confirmed the credential — it rejects a revoked one with a 4xx,
which TokenRefreshClient maps to TokenExpiredException — so the verify call
adds cost and nothing else. Record the confirmation in last_verified_at, and
let the daily sweep trust it for 12 hours the way VerifyUpcomingPostConnections
already does, so it stops re-reading accounts a refresh just proved valid.

Same simulation after the change: 0 billed reads, 0 minutes expired.

Two existing tests asserted the old policy (verify-first, refresh_token left
unrotated) and now assert the new one. The rotation test still guards what
made that policy attractive: a proactive rotation must not trip a
false-positive disconnect.

* Don't disconnect an account whose access token still works

Refreshing instead of verifying removed a safety net the old verify-first
path had: if the refresh is rejected, the account was marked TokenExpired
outright. But a rejected refresh does not mean the connection is dead.

X and LinkedIn single-use their refresh_token, so a token a concurrent
refresh already consumed comes back 4xx while the current access_token keeps
working. An account with no refresh_token at all fails even earlier, without
a single call being made. Verified against main: both cases stayed Connected
before, and became TokenExpired after. PublishToSocialPlatform hard-fails
every post for a TokenExpired account, so this killed posts the access_token
would have published, up to 30 minutes before the token was actually due to
expire — and emailed the owner a disconnect notice for it.

Fall back to verifying the access token before disconnecting. This is the
only path in the job that reaches the billed verify endpoint, and only after
a refresh has already been rejected, so the healthy path stays at zero reads
(re-confirmed: 0 reads and 0 expired minutes across a simulated 24h). A
failure that can't be attributed to the token — platform down, network blip —
leaves the account alone instead of disconnecting it on noise.

Also covers two gaps found while reviewing: a lock-skipped refresh must not
record a verification it never performed, and a platform with nothing to
refresh must not be recorded as verified either. Both already behaved
correctly; they now have tests so the daily sweep can't start trusting a
stamp nobody earned.

* Only record a verification something actually proved

Review of the previous two commits turned up four issues, all in code they
introduced.

The stamp lived inside ConnectionVerifier::refreshToken(), which
refreshThenVerify() also calls — there the refresh can succeed and the verify
that follows still fail. The stamp was already written by then, vouching for a
credential nothing confirmed. Normally harmless, because the caller marks the
account TokenExpired and recentlyProvenValid() only skips Connected ones, but
markAsTokenExpired() silently no-ops when its status lock is held by a
concurrent publish. The account then stays Connected with a fresh stamp, and
both skip-windows wave it through: 40 minutes before publishing, 12 hours in
the daily sweep. Move the stamp to the caller that owns the outcome.

TokenRefreshClient classifies on HTTP status alone and never inspects the
body, so a 200 carrying an empty access_token is stored as-is and was then
recorded as healthy. (A missing key rather than an empty one can't get that
far — access_token is NOT NULL, so the write throws first.) Guard on a filled
token.

The fallback added in 9775578 called verify(), which for an already-expired
account — RefreshExpiringTokens selects those too — runs refreshThenVerify()
and re-sends the refresh_token the provider just rejected, and on Bluesky
re-runs the password re-auth AT Proto rate-limits per account. Its own
docblock claimed it only reached the verify endpoint. Add
verifyAccessToken(), which checks the stored token and nothing else.

VerifyWorkspaceConnections read last_verified_at without ever writing it, so
an account it had just confirmed healthy still burned a fresh call minutes
later when a post entered the risk window. Stamp on its success path too.

Also corrects the RefreshExpiringTokens docblock, which still described the
verify-first behaviour removed in c0d0d57.

Re-ran the 24h whole-scheduler simulation: still 0 billed reads, 0 expired
minutes, account Connected at the end.

* Fall back to the token a concurrent refresh persisted

The fallback added in 9775578 judged the in-memory access_token, which is
exactly the one that is stale when a refresh loses a race.

X single-uses the refresh_token, so when two refreshes overlap the loser's
token comes back 400 invalid_grant. Its in-memory instance still holds the
pair the winner has already rotated away, so verifying it 401s and the
account is marked TokenExpired — while the row in the database holds a
perfectly healthy token the winner just wrote.

Verified against main: a concurrent rotation leaves the account Connected
there and TokenExpired here. refreshThenVerify() already handles this by
reloading and retrying with whatever was persisted; the new path skipped that
because it never went through refreshThenVerify.

Reload before judging. This is the failure path only, so the healthy path is
untouched — the whole-scheduler simulation still reports 0 billed reads, 0
expired minutes, Connected at the end.

* Don't record a verification when the lock skipped the refresh

refreshToken() returns normally — no exception — when another process already
holds the per-account lock, so the caller can't tell "refreshed" from "did
nothing". Moving the stamp out of the verifier in 4d2795a lost that
distinction: RefreshSocialToken stamped last_verified_at on a run that made
zero HTTP calls.

The account is then vouched for by nobody: the daily sweep skips it for 12
hours and the pre-publish check for 40 minutes. If the concurrent refresh
also failed, nothing ever confirmed the credential.

Have refreshToken() report whether it actually ran. Callers that ignore the
return value are unaffected.

The test meant to cover this called refreshToken() directly rather than going
through the job, so it kept passing while the job path was broken — it now
exercises the job and asserts no HTTP call was made.

* Close the two concurrency gaps the refresh path leaves open

Both are pre-existing, but this branch raises the exposure to them from 12 to
16 refreshes per account per day.

The per-account lock lasted 30 seconds, exactly the HTTP client's default read
timeout — so a refresh could outlive the lock that protects it. Bluesky is the
worst case, refreshing with two sequential calls (refreshSession, then the
createSession re-auth), each bounded by connect + read timeouts: up to ~80
seconds under one 30-second lock. Once it lapses, a second process refreshes
with the same single-use refresh_token and one of the two is rejected. Name
the TTL, set it past the ceiling, and write down the invariant so a future
slower refresh doesn't quietly break it.

RefreshSocialToken was not unique. RefreshExpiringTokens re-selects an account
until token_expires_at moves, and that only happens once the job runs — so a
queue more than one tick behind stacked a job per tick for the same account,
each rotating a single-use refresh_token again for nothing and widening the
gap where a worker death loses the pair. Key it by account like
VerifyUpcomingPostConnections already does.

Cadence is unchanged: the whole-scheduler simulation still reports 16
refreshes, 0 billed reads and 0 expired minutes over 24h.

* Correct which providers actually single-use their refresh_token

Checked each provider's official documentation rather than carrying the
assumption forward.

LinkedIn does not rotate. Its refresh docs are explicit: "the lifespan or Time
To Live (TTL) of the refresh token remains the same as specified in the
initial OAuth flow (365 days)" — the same token comes back with a decreasing
refresh_token_expires_in, and only the access token is reissued. The claim
that it single-uses the token predates this branch, but a docblock added here
repeated it.

Bluesky does rotate, and belongs in the list instead: com.atproto.server
.refreshSession declares refreshJwt as a required output field, so every
refresh mints a new one.

Verified alongside, all matching what the code already does:

  X          access token 2h, refresh single-use with rotation
  Bluesky    refreshJwt rotates; createSession is rate-limited per handle
             (30/5min, 300/day), which the fallback re-auth path shares
  TikTok     access 24h, refresh 365d, "may be different — you must use the
             newly-returned token", which refreshTikTokToken does
  LinkedIn   access 60d, refresh 365d fixed, not rotated
  Google     does not rotate on refresh; 100 refresh tokens per account per
             client, so the higher refresh rate on YouTube carries no
             rotation risk

* Read X post metrics from the timeline that already returned them

Analytics fetched the account's timeline for post ids, then turned around and
looked the same ids up again through GET /2/tweets purely to read the
public_metrics the first request could have returned. The timeline call asked
for start_time, end_time and max_results — never tweet.fields.

Both endpoints bill per Post returned, so the second pass claimed the same
resources a second time, took a second round-trip, and spent a second slice of
the same rate limit. For an account with 250 posts in range that is 6 requests
where 3 will do.

The saving is in round-trips and rate limit rather than dollars: X deduplicates
a resource within a 24-hour UTC window, so the second read of an id already
read that day is not charged again. But the docs call that a soft guarantee
that "may result in resources not being deduplicated" — this stops leaning on
it for 250 resources per analytics load.

Behaviour is unchanged: same totals, same 5-page ceiling, same empty result
when the account posted nothing in range. The page cap is now a named constant,
since it bounds what one load can cost as much as how long it takes.

Adds the first tests for XAnalytics::getMetrics, covering the totals, the
pagination, and that the metrics arrive on the timeline request.

* Cover the analytics paths a happy-path test walks straight past

Three gaps in what the previous commit's tests actually assert:

A timeline page failing mid-pagination breaks out of the loop and returns
whatever was collected. Nothing pinned that — partial data beats an exception
on a dashboard someone is looking at, and a future refactor could quietly turn
it into one.

A post can come back without public_metrics. The accumulator defaults each
metric to 0, so it contributes nothing instead of erroring, which also wasn't
covered.

An account with no posts in range returns [] rather than a list of zeros, so
the UI can tell "nothing posted" from "posted, no engagement".

Also makes the routing test's mock return explicit. Without andReturn, Mockery
hands back a falsy default for the new bool return type, so the assertion about
routing was passing while silently exercising the lock-skipped branch. The test
still asserts only what its name claims, but no longer depends on a mock
default to get there.

* Close five issues an independent review found in this branch

All five sit in code these commits introduced.

Instagram and Threads must fail loudly. Their long-lived token is extended in
place and cannot be renewed once it expires, and RefreshExpiringTokens picks
them up a full day ahead precisely so there is time to react. The fallback
added in 9775578 applied to them too, so a permanently rejected extension on a
token that still reads left the account Connected — the daily sweep passed as
well, since verify() succeeds on it — and the owner learned about it only after
the token died unrecoverably, while every tick retried the rejection for 24
hours. The fallback now applies only to platforms that rotate a refresh_token,
which is what it was written for.

The lock went the wrong way. Lengthening it to 120s in 5001c9a treated the
scheduler as the only caller, but publishers wait on the same lock: one left
behind by a worker that died mid-refresh makes refreshToken() return false, and
the publisher falls through and publishes with an expired token. That window
was 30s and had become 120s. Bound the calls instead — a token endpoint answers
in milliseconds, and 8s read / 4s connect keeps even Bluesky's two sequential
calls under a 30-second lock, back to where main had it.

Reloading the account can throw. $this->account->refresh() sat outside the try
in the fallback, and an exception raised inside a catch block is not caught by
a sibling catch. With tries = 1, an account deleted mid-run put the job in
failed_jobs. VerifyUpcomingPostConnections guards this same race explicitly.

An empty access token was detected but not acted on. recordVerification()
declined to stamp it, yet the refresh still counted as a success — and the
refresh method had already pushed token_expires_at two hours out, so the
account left the window looking healthy while every publish 401d. Mark it
expired, which is what verify() used to do on the same input.

refreshToken() claimed "whether a refresh actually ran" but returned true for
platforms whose match arm does nothing. Only recordVerification() re-checking
hasTokenRefreshFlow() kept that from mattering. The guard is now explicit and
the contract true at the source.

Also settles the tweet.fields question against the live API rather than the
docs, which contradict each other: the OpenAPI spec names the parameter
post.fields, while the Fields guide and every example use tweet.fields. Both
are accepted and both return public_metrics. An unrecognised name returns 200
and silently omits the field — no error — so the name being right is load
bearing, and it is.

* Guard the match that no longer has a default arm

Dropping `default => null` from refreshToken() in af379af made the return value
honest, but it also turned a missing case into an UnhandledMatchError at
runtime. The arms and hasTokenRefreshFlow() currently agree on the same nine
platforms, and nothing enforces that: adding a platform to the predicate
without an arm would fail in production, on a queue worker, for one platform's
accounts only.

The test walks every platform claiming a refresh flow and fails by name if the
match has no arm for it. Verified it catches the real thing by temporarily
adding Facebook to the predicate — it failed with "facebook claims a refresh
flow but refreshToken() has no arm for it" — rather than trusting a green run
on code that already agrees with itself.

* Make the per-platform guard fail on a broken client chain

The test added in the previous commit swallowed every Throwable except
UnhandledMatchError, so it proved a match arm existed and nothing more. Routing
all nine refresh methods through refreshHttp() in af379af rewrote how each one
builds its request, and this test would have passed just the same if one of
those chains no longer worked.

It now fails on any exception, naming the platform, and asserts each refresh
actually put a request on the wire — a chain that breaks during construction
raises before anything is sent, so an empty recording is the signal.

Verified by breaking Pinterest's chain on purpose: "pinterest refresh threw
BadMethodCallException: Method PendingRequest::withHeadersTypo does not exist."
All nine send their request with the chains as they stand.

* Stop trading a recoverable failure for an unrecoverable one

A max-effort review found six issues, three of which undo a trade the previous
round got backwards.

Every refresh method wrote the response straight over the stored credential:
`'access_token' => data_get($data, 'access_token')`. A 200 carrying no token
therefore destroyed a working one — and on Instagram and Threads, where
refresh_token is set to the same value, both halves at once. The blank() check
added in af379af only noticed after the damage was persisted, then marked the
account TokenExpired, which RefreshExpiringTokens no longer selects — so one
glitchy-but-successful response emailed the owner and forced a manual
reconnect. Guard before the write instead, treat it as the platform
misbehaving, and the stored pair survives for the next tick to retry. The
detection branch downstream is now unreachable and gone.

Tightening the refresh timeout to 8s was the wrong fix for the lock problem.
refreshHttp() is shared with 24 publish and analytics call sites, and for X,
Bluesky and TikTok the refresh_token is single-use: abandoning a request the
provider has already processed loses the rotated pair permanently and costs the
user a reconnect. Giving up sooner makes that more likely, not less. Restore
generous timeouts and put the lock back above them. The cost of erring long is
that a worker dying mid-refresh holds the lock while a publish falls through
and retries — recoverable, unlike a lost rotation. Both constants now say which
way they are wrong on purpose.

The hasTokenRefreshFlow() guard in recordVerification() was dead: refreshToken()
already returns false for those platforms, so the branch was never entered. The
test claiming to cover it calls refreshToken() directly and never reaches it.

The command reported "Dispatched N" for a number it cannot know. dispatch()
returns a PendingDispatch whether or not ShouldBeUnique discarded it, so the
count overstated itself during exactly the backlog someone reads that line to
diagnose. It now reports accounts in the window, which is what it actually
measured.

Not changed: the daily sweep still skips accounts a refresh keeps fresh. That
is the deliberate decision this PR is built on — a refresh replaces the access
token rather than inspecting it, so there is nothing left for a billed read to
confirm.

Re-verified live after the changes: the real job still rotates the token
against api.x.com and leaves the account connected.

* Cover the tokenless-200 guard on every platform, not just X

The guard added in the previous commit protects nine refresh methods; only X
had a test. Each provider reads a different field name out of the response, so
a regression would land on one platform at a time and the suite would stay
green for the other eight.

The test drives every platform claiming a refresh flow through a 200 that
carries no token, and requires each to refuse with PlatformUnavailableException
— nothing is provably dead, so the next tick should retry rather than anyone
being disconnected — while leaving the stored credential untouched.

Verified it fails usefully by dropping the guard from one platform: 'threads
should refuse a tokenless 200 cleanly, got QueryException: null value in column
access_token violates not-null constraint'. Without naming the platform the
failure reads as an unrelated database error, since the write also poisons the
surrounding transaction.

* Stop the fallback from reading every failure as good news

A fifth review found the concurrent-refresh test passing without ever
exercising what it claims to cover, and the reason it could is a real bug.

access_token is an encrypted cast. The test wrote the winner's pair with
DB::table()->update(), which stores plaintext, so reading it back raised
DecryptException — and accessTokenStillWorks() caught Throwable and returned
true. Green test, zero coverage of the recovery that justifies the method
existing. It now writes through the model and asserts the verify call actually
happened; removing the reload makes it fail.

The catch is the bug. Treating any non-TokenExpiredException as "the token is
healthy" means an APP_KEY rotation, a corrupted column, or an UnhandledMatchError
from a newly added platform leaves the account Connected forever while every
publish hard-fails, and nobody is told. It now names the outcomes that earn the
benefit of the doubt — platform down, network dropped, account deleted mid-run
— and lets the rest surface. Loud is right here: an APP_KEY rotation breaks
every account at once, so failing the job where an operator sees it beats
disconnecting every user.

Refusing to persist a tokenless 200 also had no way out. The account kept
retrying every 15 minutes forever, and the daily sweep counts
PlatformUnavailableException as verified, so it was never disconnected and
never reported. Now the retry only continues while there is a live token
behind it: once that expires and renewal still fails, the connection is dead
in practice and says so.

Also corrects the refreshHttp() docblock, which claimed a blast radius the
private method does not have — refreshToken() is what those 24 call sites
reach — and records in VerifyWorkspaceConnections that short-TTL platforms
never being re-verified is the intended consequence, not an oversight.

* Stop a bad hour at the provider from disconnecting anyone

The escalation added last round was wrong, and two neighbouring paths had the
same shape of bug.

Marking the account expired whenever a PlatformUnavailableException hit an
already-expired token looked like it closed a silent-rot gap. But that
exception is what TokenRefreshClient raises for 5xx, 429 and connection
timeouts — so X rate-limiting for forty minutes around a two-hour token's
expiry disconnected the account, emailed the owner, and hard-failed every
scheduled post, with only the daily sweep to undo it. The rot it was meant to
prevent surfaces at publish time anyway. Reverted: a transient failure never
disconnects.

refresh_token was left unguarded when access_token was hardened. data_get()
only falls back when a key is absent, so a provider answering with an explicit
"refresh_token": null wiped the stored one — and the next tick then threw "no
refresh token available" without making a single call. Guarded in the same four
places, falling back on blank rather than on missing.

A held lock reported "nothing refreshed" even when the token was already dead,
handing the caller a credential it knew was expired. The publisher posts with
it, takes a 401, and PublishToSocialPlatform finalises the post as failed and
disconnects the account — over a lock a dying worker left behind, for the two
minutes it survives. It now says transient, which is what a refresh someone
else is already running actually is.

VerifyWorkspaceConnections promoted TokenExpired accounts back to Connected on
verifyAccount()'s return value, which is also true for "could not check, don't
disconnect". An unreachable platform therefore told owners their reconnect had
worked when nothing was verified. Promotion moved next to the successful
verify.

Each of the four is pinned by a test, and each test was checked by reverting
the fix and confirming it fails.

* Keep a lock collision off the analytics page

Round seven found one real regression from round six, one consistency gap, and
one stale comment.

Reporting lock contention as transient was right for the publish path, which
reschedules, but analytics calls refreshToken() bare and AnalyticsController
has no try/catch — and there is no renderable handler for
PlatformUnavailableException. A user opening analytics for an account whose
token expired while the scheduled job held the lock got an HTTP 500 where the
same request previously returned empty metrics. Reproduced before fixing:
"Expected response status code [200] but received 500". The controller now
degrades to empty numbers and still reports, which also covers the same 500
for any platform whose refresh 5xx'd — possible before this branch too.

The fallback verify was throwing away a result worth keeping. It is a billed
call on X and it proves the token alive exactly as a refresh does, so the
pre-publish check was paying to ask the same question minutes later. Stamped
like the other two sites.

Also rewrites a comment in rotatedTokenFrom() that described the data_get()
call it replaced rather than the blank() check beneath it. The review also
reported a false @throws on that method; it has no docblock at all.

Both fixes checked by reverting them and confirming the new tests fail.

* Degrade analytics on an unreachable platform, not on a bug

The rescue() added last commit caught Throwable, so it did not just absorb a
platform being down — it absorbed everything. A TypeError in any metrics
service rendered as "this account has no activity", with a log line as the only
sign anything was wrong. Reproduced: a metrics service throwing RuntimeException
returned 200 with empty metrics.

This is the same mistake the review flagged two rounds ago in
accessTokenStillWorks(), where treating any exception as "the token is healthy"
hid real failures. Narrowed the same way: PlatformUnavailableException and
ConnectionException degrade to empty numbers and still report, everything else
surfaces as the 500 it is.

The match moved into a named method so the intent has somewhere to live, since
the reason for the narrow catch matters more than the catch itself.

Both directions are pinned: widening the catch back to Throwable fails the
bug-is-not-hidden test, and removing the degradation fails the lock-collision
test.

* Trim the commentary back to what the code cannot say

RefreshSocialToken had 72 comment lines against 95 of code — 43% of the file.
The rest of the branch was heading the same way: two constants in
ConnectionVerifier carried twelve- and eight-line docblocks, and
VERIFIED_WITHIN_HOURS had twelve lines explaining a number.

Most of it was history rather than reasoning: what the code used to do, which
review round asked for a change, the full argument for a decision the code
already states. Kept the parts a reader cannot recover — why a catch is narrow,
why a stamp is not written inside refreshToken(), why the lock has to outlast
the timeouts — and cut the rest.

shouldTrustAWorkingAccessToken() went with it: a one-line method behind a
twelve-line docblock, now the condition it wrapped, inline where it is used.

No behaviour change; full suite unchanged at 3786.
2026-08-20 17:58:16 -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
5bb39da598 fix(permissions): enforce workspace roles across backend and UI
Viewers could mutate posts, automations and trigger AI write endpoints,
and every role saw create/manage affordances that 403'd on click.

Backend (security):
- PostPolicy update/delete now require member+ (was tenancy-only), which
  also gates the AI write endpoints that authorize('update')
- AutomationPolicy create/update/delete require member+; activate/pause
  delegate to update
- AutomationController authorizes index/store/show; AnalyticsController
  authorizes view
- Comments stay open to members incl. viewer (by design)

Frontend (UI gating via new useWorkspaceRole composable):
- Sidebar: create post / create workspace / automations / library nav
- Accounts grid: connect / disconnect / reconnect (admin+)
- Members: invite / change role / remove / cancel invite (admin+)
- Account billing tab (owner); posts index + calendar create affordances

Tests: PostPolicyTest, AutomationPolicyTest (all four roles) and an
end-to-end WorkspaceRolePermissionsTest; aligned the automation test
suites' account/workspace setup with role pivots.
2026-06-22 16:31:54 -03:00
Paulo Castellano
b9fa8be513 Group Telegram service classes under Services/Social/Telegram 2026-06-14 13:32:03 -03:00
Paulo Castellano
ba246a3ddd Add Telegram analytics: per-post reactions (webhook) + channel subscribers 2026-06-14 11:18:40 -03:00
Paulo Castellano
4cf601e618 feat: add YouTube Analytics with 7 channel metrics
Integrates YouTube Analytics API v2 to display channel-level metrics
(views, minutes watched, avg view duration, avg view percentage,
subscribers gained/lost, likes) with date range support and caching.
2026-04-14 13:10:27 -03:00
Paulo Castellano
cb91529964 chore: working 2026-04-02 17:57:06 -03:00