31 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| daf9d45c53 |
Add user:create artisan command
All checks were successful
Setup EC2 Tools / setup-server (push) Successful in 1m31s
|
|||
|
|
d8149ef058
|
Remove the automations module (#333)
* Remove the automations module Drops the visual workflow builder end to end: actions, node runners, commands, jobs, models, observers, policy, resources, requests, routes, broadcast channel, scheduler entries, Horizon supervisor, Vue pages and components, canvas undo/redo history, CodeEditor, translations, factories and tests. A new migration rewrites posts.created_via = 'automation' to 'web' and drops the five automation tables. The CreatedVia::Automation enum case is removed. The 2026-08-21 social-account identity migration now skips its automation node repointing when the automations table no longer exists, so it stays re-runnable after the drop. Orphaned dependencies removed: simplepie/simplepie, @vue-flow/*, codemirror and @codemirror/*. * Drop the orphaned common.beta translation key * Remove automation leftovers: ResolvableUrl rule, feed fixtures, useShortcut, nav badge * Drop the unused chart wrapper and @unovis packages * Address review: keep the shipped migration untouched, drop the dead previewOnly chain - Restore 2026_08_21 migration to exactly what production ran; the rehearsal test now recreates the automations table it expects instead. - Mark the drop migration's down() irreversible like its siblings. - Simplify DropAutomationTablesMigrationTest to the sibling shape. - Remove previewOnly / aiGenerateVariants: the only caller that set the prop was the deleted automation Generate node. - Run pint over lang/*/common.php after the beta key removal. * Exercise the drop migration against the real automation tables and their FKs * Drop DuplicateIdentityRehearsalTest: it re-ran a frozen migration that reads the removed automations table |
||
|
|
58d8e066b5
|
Add workspace webhooks and drop the unused automation webhook node (#326)
* Add workspace webhooks and drop the unused automation webhook node. Give workspaces HMAC-signed outgoing webhooks for the post lifecycle, with retry, auto-pause, replay, and live logs, and keep HTTP Request as the only outbound automation node. * Tighten webhook controller and validation after review. Drop the redundant workspace redirects, prune logs without counting, and validate events/status with Rule::enum. * Move leftover webhook UI copy behind i18n. HTTP status phrases, delete-cancel, and validation attribute names were still English literals. * Build the webhook-paused email through Maizzle. The hand-written Blade skipped the shared layout, header, and footer used by the other mail templates. * Cover real webhook dispatch paths and restyle the webhook pages. * Ask for the shared delete keyword when confirming a webhook delete. The endpoint URL is a poor confirm string; posts and assets already use the common "delete" keyword. * Fix webhook review blockers so CI can go green. Drop leftover French automation keys, stop mutating Inertia log props, and show delivered_at instead of created_at. * Close the remaining webhook review gaps. Keep Echo log updates across infinite scroll, align the channel with the policy, persist log ids across retries, and fail unknown automation nodes without throwing. * Stop webhook delivery after disable and record last sent only on success. Queued jobs now skip paused or disabled endpoints unless the user replays, and changing the URL re-pings it first. * Limit webhooks to owners and admins, and encrypt signing secrets. Members can no longer create or inspect outgoing integrations, and secrets stay encrypted at rest. * Cover webhook secret hiding, skip-ping, and failed-delivery edges. * Send the full post on webhooks after labels and platforms are saved. * Fix webhook payloads for integer media ids and type webhook status. * Split the webhook show page into focused components. * Reset live webhook logs when switching endpoints. * Keep the newest webhook logs at the top after live merges. * Cast media item ids to string without the extra scalar check. * Add post.unscheduled webhooks and put the log id on the envelope. Unscheduling is now a first-class event, and receivers can send the delivery id back so we can find the matching log. * Translate webhook event names in the UI. * Make the webhook show page full-width and stop stacking flash toasts. * Translate remaining webhook UI copy in every locale. * Sign webhook pings and drop author email from the payload. * Send signed webhook tests after create instead of pinging on save. Create and update only block private URLs so the receiver can copy the secret first. The show page then sends a signed webhook.test with an object data envelope. * Polish webhook test UX and always mint the dispatch log id in the job. Keep send-test in the actions menu (its own group) and drop the leftover constructor param so retries reuse the serialized id instead of a caller-supplied one. |
||
|
|
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.
|
||
|
|
0eff22dc0d
|
Remove the post-templates feature (#296)
* Remove the post-templates feature Removes the browsable post-templates catalog end to end: routes (app.post-templates.index/apply), controller, form requests, resource, Registry/PostTemplateData/TemplateNotFoundException services, the templates:report console command, the templates/ catalog (36 files across en/es/pt-BR), the templates Index.vue page, the template card on the post Create.vue choice screen, and its i18n keys across all 16 locales. Also removes TemplateContextResolver, which fed catalog examples into PostContentGenerator's AI prompt. The "examples" block (with its heading) is removed from generator.blade.php rather than left empty; AI generation no longer references the templates catalog. The AI content-template system under app/Ai/Templates (image/tweet card templates for the AI post wizard) and TemplateImageGenerator are unrelated and untouched. * Update FormRequest naming examples after the post-templates removal CLAUDE.md and the Cursor rules cited ApplyPostTemplateRequest and IndexPostTemplateRequest as naming examples. Both classes were deleted with the feature, so the guidance pointed at files that no longer exist. * Share the AiTemplate type between the create screen and the AI wizard Create.vue and AiPostWizard.vue each declared their own AiTemplate interface, and they had drifted: the wizard's carried applies_brand_visuals, the create screen's did not. TypeScript treated them as unrelated types with the same name, which surfaced as a TS2719 error where the templates prop is passed between them. The backend sends seven fields (PostController::create), so the shared declaration follows the payload rather than the union of the two copies. |
||
|
|
4546425532
|
Resume in-flight Instagram and TikTok publishes without duplicates (#281)
* Improve asynchronous social publishing reliability
* fix: resume asynchronous social publishes
* fix: preserve publish checkpoints across retries
* fix: harden resumable publish lifecycle
* fix: clean retry resources on terminal failures
* test: cover resumable social publishing edge cases
* feat: add failed post retry command
* chore: remove retry command ai rule
* fix: require confirmation for post retries
* chore: remove ai rules index
* chore: remove ai social rule
* refactor: clarify TikTok derivative path validation
* refactor: simplify social publishing retries
* refactor: further simplify social publishing retries
* refactor: retry all failed post platforms
* style: import throwable in social retries
* refactor: decouple TikTok cleanup from image format
* refactor: extract missing publish scopes
* refactor: encapsulate missing scope failure
* fix: resume failed publishes and treat Instagram rate limits as transient
Keep TikTok/Instagram checkpoints on posts:retry so a manual retry does not
start a duplicate remote post. Classify Meta BUC 400s on Instagram status
polls as retryable via GraphError.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover resume paths and transient Instagram rate limits
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: resume posts:retry only for in-flight publish failures
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: resume posts:retry via ErrorCategory instead of string lists
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: handle Instagram PUBLISHED and EXPIRED container statuses
Treat EXPIRED as a terminal server error so posts:retry starts over, and complete already-published containers without a second media_publish.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: recover published Instagram stories from /stories
Stories are not on GET /{ig-user-id}/media. Resume a PUBLISHED story container from the stories edge so we do not bind a feed post id.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover Instagram EXPIRED retry and published recovery paths
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: stop guessing Instagram media ids from recent /media
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: checkpoint TikTok publish_id and keep in-flight photo derivatives
Persist publish_id right after /init/ so a crash can resume without a second publish. Keep hosted photos while that id is resumable, including token expiry on status fetch; prune only after success or a confirmed remote failure.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover remaining TikTok in-flight derivative edge cases
Guard the empty publish_id prune path, account guards without a checkpoint, and video status 401 after /init/.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: map TikTok publish statuses with an official enum
Use PublishStatus for status/fetch values from the Content Posting API. Keep only the documented cases, including FAILED as the terminal failure.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: share in-flight publish checkpoint keys
Read TikTok and Instagram resume state through one helper so publishers, posts:retry, and derivative cleanup agree on the same keys.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: retry Instagram media_publish after transient Graph failures
A 500/code 2 after Meta already published left the job Failed as unknown.
Treat that as still-processing so resume can confirm PUBLISHED instead of posting again.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: resume Instagram publish after dropped Graph connections
A timeout or connection reset after Meta already published was marked unknown.
Treat it as still-processing so resume can confirm PUBLISHED instead of posting again.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
|
||
|
|
95bb537f3d
|
chore: remove LinkedIn Page post URL backfill command (#273)
Its one-time job is done — already run in production to repair platform_url on posts published before #272's fix. No longer needed going forward. |
||
|
|
29e90c52af
|
fix: LinkedIn company page post URL missing the post ID (#272)
* fix: LinkedIn company page post URL missing the post ID
LinkedInPagePublisher::postUrl() built a company/{username}/posts/
URL but never interpolated $postId into it, so the "view on LinkedIn"
link for company page posts always ended at the trailing slash.
Per LinkedIn's Posts API docs, the correct public URL for any
published post (member or organization) is always
feed/update/{postId} — so the override is removed and the class now
inherits the correct base implementation.
Closes #271
* address code review: backfill historical URLs, fix stale docblock, close test gaps
- Add social:backfill-linkedin-page-post-urls to recompute platform_url
for LinkedIn Page posts published before the fix, whose broken
company/{username}/posts/ URL was already persisted and never
recomputed. Supports --dry-run.
- Fix AbstractLinkedInPublisher::postUrl() docblock, which still
claimed company pages override it after the override was removed.
- LinkedInPagePublisherTest: assert result['url'] in the image and
carousel publish tests (previously only asserted result['id']);
add a null-postId case; collapse the two username-branch tests
(now dead code) into one dataset-driven test plus a dedicated
null-postId test.
|
||
|
|
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.
|
||
|
|
27287aa130
|
fix: Pinterest video processing timeout — longer poll + retry (#246)
* fix: give Pinterest video processing more time and retry on timeout A valid ~54s video pin failed after ~90s of polling while Pinterest was still processing. Extend the poll window to ~5 minutes and treat timeout as platform unavailable so PublishToSocialPlatform reschedules instead of failing the post on the first attempt. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: use Laravel Sleep for Pinterest media processing polls Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: inline Pinterest video processing poll constants Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: map Pinterest media upload statuses to an enum Use the official MediaUploadStatus values (registered, processing, succeeded, failed) instead of comparing raw strings in the publisher. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: trim Pinterest media processing docblock * fix: cap platform-unavailable retries and recover stuck retrying posts Stop infinite reschedules after 6 attempts with a user-safe failure message, keep technical detail in error_context, recover Retrying platforms in social:recover-stuck-posts, and drop unused isTerminal(). Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: remove unused failedCount in RecoverStuckPosts Co-authored-by: Cursor <cursoragent@cursor.com> * fix: skip final Pinterest poll sleep and localize recover timeout Co-authored-by: Cursor <cursoragent@cursor.com> * fix: raise publish job timeout headroom and ignore already-failed platforms Give social publish jobs 15 minutes so Pinterest media polling fits under the worker limit, bump Horizon/redis retry_after above that timeout, and skip handle/failed when the platform is already Failed so delayed jobs cannot revive posts recovered by social:recover-stuck-posts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: restore social-publishing and ai-assistant horizon supervisors Co-authored-by: Cursor <cursoragent@cursor.com> * fix: harden Pinterest 401 handling, unique publish jobs, and recover JSON Treat media-status 401 as TokenExpired, make PublishToSocialPlatform unique per platform+attempt so retries still queue, and persist recover error_context via Eloquent casts instead of manual json_encode. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: mass-update stuck post platforms without per-row each Eloquent query updates already bind JSON arrays correctly here, so one UPDATE is enough — no manual json_encode and no N model writes. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: simplify Pinterest media processing poll loop Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: simplify publish job retry and terminal status checks Co-authored-by: Cursor <cursoragent@cursor.com> * fix: do not finalize posts while platforms are still retrying Co-authored-by: Cursor <cursoragent@cursor.com> * test: cover Pinterest timeout, unique jobs, and recover edge cases Co-authored-by: Cursor <cursoragent@cursor.com> * fix: retry Pinterest media poll on connection errors and tighten tests Co-authored-by: Cursor <cursoragent@cursor.com> * fix: remove ineffective TypeError import that breaks CI Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
4d5ca6b274 |
Centralize the Meta long-lived token TTL and clarify the extension helper
The 60-day fallback used when Meta omits expires_in was duplicated as a bare 5184000 across the Instagram/Threads connect and refresh code; it now lives in one place, Platform::LONG_LIVED_TOKEN_TTL_SECONDS. Also renames Platform::extensionModelValues() to accessTokenExtendingPlatformValues() so the name states what it returns without needing the extendsAccessTokenOnRefresh docblock. |
||
|
|
94d0156ec0 |
Give Instagram/Threads a wider proactive-refresh window
Extension-model tokens (Instagram/Threads) can't be refreshed once they expire, so the shared 30-minute cron window left only a ~15-minute buffer against queue backlog on the default queue — and a lapse forces a full reconnect. Rotating platforms go through verify() and won't rotate a still-valid token, so they keep the tight 30-minute window; extension platforms now get a 24-hour lead via a per-model query. |
||
|
|
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 |
||
|
|
deb1c6fa69 | Extract Telegram webhook registration into an action | ||
|
|
a4cf8aa4ce |
Add Telegram connection flow (controller + webhook)
Connect a channel by issuing a one-time code the user posts as /connect <code> in their channel. A secret-token-guarded webhook matches the code, links the channel as a SocialAccount (chat_id in meta), and records it on the request so the connect endpoint can poll for completion. Adds the TelegramConnectRequest model + migration, the connect/status endpoints, the public webhook route (CSRF exempt), a ConnectionVerifier branch (getChat liveness), and a telegram:set-webhook command. Tests cover the code issue, webhook link, secret rejection, expired/ unknown codes, status polling, and the command. |
||
|
|
a129b6b5cd |
Denormalize automation trigger_type into an indexed column
The scheduler command ran every minute and loaded all active automations, then filtered by trigger_type in PHP because that value lived buried in the nodes JSON array — effectively a full-table scan plus a JSON decode per row each minute, discarding every non-schedule automation. Derive trigger_type into a real, indexed column on save (recomputed in the existing saving() hook so it can never drift from nodes) and filter on it in SQL. Applies to both the schedule firer and the post-trigger dispatcher. |
||
|
|
9a692b4608 |
Enhance automation functionality: Introduce workflow variables and improve node validation
- Added support for workflow variables in automations, allowing users to define reusable values. - Implemented validation for Generate nodes to ensure intended image counts align with selected accounts. - Updated automation models and requests to handle new variables, including encryption for sensitive data. - Enhanced UI to display variables and their management within the automation editor. - Improved error handling for webhook and HTTP nodes to prevent requests to invalid URLs. - Refactored various components for better context resolution during automation runs. |
||
|
|
4efaa0bf99 |
Harden automations module: full-post generation, reliable runs, editor UX
Generation
- Generate node now produces the full post (text + AI image + carousel)
via a shared PostImagePipeline extracted from StreamPostCreation
- Generate config UI mirrors the /posts/create wizard (carousel slide
count, include-image toggle); drop the decorative format/unsplash keys
Flow correctness
- RSS/HTTP nodes expose named has-items (default) and no-items output
handles, labeled and colored like the Condition node
- AdvanceAutomationRun records a no_matching_edge terminal instead of
completing silently; "0 new items" feedback in the test panel
- Manual/test runs no longer persist the production dedup watermark
Run reliability
- Pause truly halts in-flight runs (production only; manual test runs
always run regardless of automation status)
- ProcessAutomationNode::failed() marks the run failed
- automation:recover-stuck-runs and automation:prune-dry-runs commands
Webhook / HTTP
- Branded User-Agent (config-driven) on outbound webhook + http_request
- Webhook fails on invalid JSON instead of silently sending {}
- HTTP custom headers editor; CodeMirror-based CodeEditor for JSON
Editor UX
- Header Test button only opens the panel; the panel has a Run button
(saves first) and owns the with-real-data toggle
- Clicking a node closes the test panel and opens its config
- Node cards: max-width + truncate so long URLs don't grow the node
|
||
|
|
b23ab0166e |
feat(automations): implement automation features and UI enhancements
- Added new automation-related routes and controllers for managing automations. - Introduced automation nodes in the UI with distinct styles and interactions. - Updated sidebar to include navigation for automations. - Enhanced post creation logic to support automation metadata. - Refactored content type and platform enums into types for better type safety. - Added localization for automation-related terms in English, Spanish, and Portuguese. - Improved error handling in various components to accommodate new features. |
||
|
|
3ba47ad02a |
fix(social): proactive token refresh actually refreshes (not just verifies)
Three orthogonal fixes that together close the gap where social tokens
were silently aging out without ever being refreshed, then dying at the
provider when the refresh_token also got revoked.
The original failure mode: a user's X token expired because the hourly
proactive-refresh cron's smart `verify()` skip-logic kept saying 'token
still works, no need to refresh', and once the token actually expired,
the cron's WHERE clause excluded it from future runs. By the time anyone
noticed, the refresh_token at X was also gone.
(C) ConnectionVerifier: rename private `refreshTokenIfNeeded` →
public `refreshToken`. Callers that want the smart 'try
access_token first' behavior keep using `verify()`. Callers that
want a proactive refresh (the cron) call `refreshToken` directly.
(B) RefreshExpiringTokens command: drop the
`where('token_expires_at', '>', now())` filter. Already-expired
tokens now get a last-chance refresh attempt before the
refresh_token also dies at the provider. Status filter
(`Connected`) still excludes accounts already marked TokenExpired.
(D) RefreshSocialToken job: switch from `verify()` to
`refreshToken()`, and on `TokenExpiredException` call
`markAsTokenExpired` so the user is notified immediately. The lock
+ transition detection in markAsTokenExpired prevents notification
spam if subsequent cron passes also fail.
Tests:
- 3 new tests for RefreshSocialToken (calls refreshToken not verify,
marks TokenExpired on TokenExpiredException, logs warning on other
errors)
- Updated RefreshExpiringTokens test to assert already-expired tokens
are now dispatched (was previously asserted as 'should NOT')
|
||
|
|
afc47e9532 | feat: transition AI usage from feature-based limits to a centralized monthly credit system with token tracking. | ||
|
|
d39124e90b | refactor: reorganize settings UI, migrate post templates to a file-based registry, and remove legacy video generation features | ||
|
|
1e1519876d | feat: replace legacy AI assistant with modular post content generation, review, and template management system | ||
|
|
d19a5c7bcf | fix: RecoverStuckPosts also recovers platforms stuck in Pending status | ||
|
|
9d0a860d87 | fix: prevent double-publish race condition, media upload auth bypass, TokenExpired disconnected_at | ||
|
|
a1df51234b | fix: review #5 — ContentSanitizer in all publishers, refresh lock, CheckSocialConnections includes TokenExpired | ||
|
|
e034c0b572 |
feat: publishing hardening — content sanitization, validation, scopes, stuck recovery
- Add ContentSanitizer: strips HTML, converts bold/underline to Unicode for LinkedIn - Add backend content length validation in all 11 publishers via HasSocialHttpClient trait - Add scope verification before publishing — checks required scopes per platform - Add RecoverStuckPosts command (every 30min) — recovers posts stuck in publishing > 1h - Add requiredPublishScopes() to Platform enum - Update SocialAccount factory with correct scopes per platform state |
||
|
|
c48c774e23 |
feat: publishing engine improvements — rate limit retry, inline token refresh, per-platform queues, proactive refresh
- Add HasSocialHttpClient trait with 429 rate limit retry (3 attempts, 5s delay) - Integrate trait into all 10 publishers (YouTube uses Google SDK) - Add inline token refresh retry in PublishToSocialPlatform job - Add per-platform Horizon queues via Platform::queue() and Platform::allQueues() - Add RefreshExpiringTokens hourly command for proactive token refresh - Fix token leaks: redact response bodies in all Log::error calls - Fix token leaks: remove $response->body() from exception messages - Fix ConnectionVerifier: redact all refresh error logs - Fix null checks on API response IDs (Instagram, Threads, Pinterest, Facebook) - Fix PublishPost::failed() to mark post as failed - Fix StoreChunkedMediaRequest: validate max 1GB total size - Fix scheduled_at validation: string → date - Fix StoreMediaRequest: images max 10MB, videos max 1GB, only MP4 video |
||
|
|
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. |
||
|
|
37d0838571 | feat: Implement social connection verification with a new command, job, and service, while removing unused language files and updating Cashier configuration. | ||
|
|
d39de0752c | feat: adding tests.. |