Commit graph

11 commits

Author SHA1 Message Date
Paulo Castellano
1bb67b7abb Keep Instagram/Threads tokens extended while still valid
Cold review caught a regression from the two previous commits. Instagram and
Threads use long-lived tokens refreshed by EXTENDING the access_token itself
(grant_type=ig_refresh_token / th_refresh_token) — they have no separate
refresh_token and CANNOT be refreshed once expired. The anti-over-rotation rule
("only refresh a token once it's actually expired") is right for rotating
single-use refresh_token platforms but wrong for these: it left IG/Threads
tokens to lapse, after which the extend call fails and the account disconnects
(~every 60 days).

Gate the anti-rotation on the platform's refresh model:
- Platform::extendsAccessTokenOnRefresh() — true for Instagram/Threads.
- SocialAccount::needsProactiveTokenRefresh() — expired for rotating platforms,
  OR expiring-soon for extension platforms (restores isTokenExpiringSoon).
- RefreshSocialToken extends (refreshToken) extension-model tokens while still
  valid, and verifies (access-token-first) rotating ones.
- All 23 publisher/analytics pre-checks now use needsProactiveTokenRefresh().

Tests: proactive job extends a still-valid Instagram token; a model test covers
the rotating-vs-extension branching; existing X/LinkedIn anti-rotation tests
are unchanged.

Refs #126
2026-07-03 09:18:45 -03:00
Paulo Castellano
3dd1804c8e Stop publishers/analytics from rotating still-valid tokens
Every publisher and analytics service proactively refreshed the token when
it was expired OR merely "expiring soon" (within 15 min), calling
refreshToken() directly. For X (and other single-use-refresh providers)
that rotated a perfectly valid access_token whenever an operation ran in the
token's final 15 minutes — the same needless rotation that breaks the
refresh_token chain and disconnects accounts.

Narrow every pre-check to refresh only when the token is actually expired. A
still-valid token is used as-is; if it expires mid-operation the existing
reactive retry (PublishToSocialPlatform) refreshes and retries.

- Drop `|| is_token_expiring_soon` from all 23 publisher/analytics pre-checks.
- Remove the now-unused `isTokenExpiringSoon` accessor (no references remain
  anywhere in the repo).
- The reactive retry path (AbstractLinkedInPublisher::retryWithRefresh) and
  the expired-token path are unchanged.

Refs #126
2026-07-03 08:30:58 -03:00
Paulo Castellano
6c6078fa6a refactor(social): drop redundant \$account->refresh() after CV refresh
Every refresh*Token method inside ConnectionVerifier already finishes
with \$account->update(...) (mutates the model) + \$account->refresh()
(reloads from DB to pick up sibling updates like LinkedInTokenSynchronizer).
The lock-contention branch in refreshToken() also calls \$account->refresh()
before returning.

So a second \$account->refresh() in the caller was always a redundant
SELECT — no scenario where it actually pulled a different value than
what CV already left in memory. Removed from all 15 call sites, plus
fixed the duplicated "Mastodon tokens don't expire" comment.
2026-05-19 09:52:49 -03:00
Paulo Castellano
fcb70b3599 refactor(social): single source of truth for token refresh
Every per-platform publisher and analytics class had its own private
refreshToken() implementation, all doing essentially the same thing as
ConnectionVerifier's per-platform refresh*Token methods. ~17 copies of
near-identical OAuth refresh logic across the codebase, which is how
the 5xx→TokenExpired bug got planted in the publish path even after we
fixed it on the hourly/daily jobs.

Consolidation:
- All publish/analytics paths call app(ConnectionVerifier::class)
  ->refreshToken($account) instead of their own implementation.
- 17 private refreshToken() methods deleted.
- refreshTokenWithLock helper removed from HasSocialHttpClient trait
  (its lock semantics are duplicated by ConnectionVerifier's per-
  account lock).
- ConnectionVerifier::refreshLinkedInToken passes $account->platform
  to TokenRefreshClient so the "LinkedIn" vs "LinkedIn Page" label
  in error messages is preserved.
- TokenRefreshClient now treats HTTP 429 the same as 5xx (raises
  PlatformUnavailableException) — replaces the retry-on-429 behavior
  that socialHttp() provided to the deleted refresh methods.

Net: -460 LOC. Single per-platform refresh implementation. Every fix
or new platform now lands in exactly one place.
2026-05-19 09:18:58 -03:00
Paulo Castellano
32e1f89adb fix(social): publish flow honors PlatformUnavailable too
The previous commits in this PR closed the loophole on the hourly /
daily token-refresh jobs. The same loophole remained on the publish
path: every per-platform publisher (LinkedIn, X, YouTube, TikTok,
Threads, Instagram, Pinterest, Bluesky and their Analytics siblings)
has its own refreshToken() called before publishing a scheduled post,
and all of those treated any non-2xx as TokenExpired — including 5xx.

Result before this commit: a Bluesky outage that coincided with a
scheduled publish would mark the account as expired and fail the post.

Changes:
- Route every refreshToken() in the 16 publisher / analytics classes
  through TokenRefreshClient::for(Platform::X)->send(...).
- TokenRefreshClient now also fills platformErrorCode from the HTTP
  status and pulls error_description / error.message from the JSON
  body, preserving the richer info LinkedIn / X / TikTok / Pinterest /
  Threads used to put on their TokenExpiredException.
- PublishToSocialPlatform catches PlatformUnavailableException
  explicitly: the post is marked failed (category: platform_unavailable,
  with http_status in error_context) but the account stays Connected.
  No retry inside this job — the scheduler reattempts the next run.

Test added: publish flow does NOT mark account expired when the
publisher throws PlatformUnavailable. Full suite: 1569 passing.
2026-05-19 09:01:02 -03:00
Paulo Castellano
1660084227 refactor(social): use config for OAuth hosts everywhere
Earlier in the PR the new configs (linkedin.oauth_api, youtube.oauth_api,
bluesky.default_service, mastodon.default_instance) were only read by
ConnectionVerifier. The same URLs were still hardcoded in the publishers,
analytics and the Bluesky auth controller — meaning a self-hosted user
setting BLUESKY_DEFAULT_SERVICE or MASTODON_DEFAULT_INSTANCE in env would
get split behavior: refresh/verify honor the override, publish/analytics
don't.

Routes all 10 remaining call sites through the same config values so the
overrides actually work end-to-end.
2026-05-19 08:34:35 -03:00
Paulo Castellano
3ae4a3653a refactor: localize analytics metrics and overhaul UI components and dashboard layouts 2026-05-06 15:39:10 -03:00
Paulo Castellano
f4fd8a2aab refactor: centralize social API base URLs into the configuration file to improve maintainability and environment flexibility 2026-05-02 13:49:20 -03:00
Paulo Castellano
f3605717c7 refactor: unify social analytics, reorganize workspace settings, and implement content validation rules 2026-05-02 12:22:42 -03:00
Paulo Castellano
817d6822b9 refactor: optimize json parsing and add missing test coverage
- Store response JSON once instead of parsing twice
- Add tests for null refresh token and token refresh failure scenarios
2026-04-14 13:36:22 -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