diff --git a/app/(app)/promote/[id]/page.tsx b/app/(app)/promote/[id]/page.tsx index c02a00fa..7adcaea0 100644 --- a/app/(app)/promote/[id]/page.tsx +++ b/app/(app)/promote/[id]/page.tsx @@ -162,7 +162,9 @@ export default async function PromoteDetailPage({ params }: Props) { p.status === "posted" ? "badge-pass" : p.status === "failed" ? "badge-fail" : "" }`} > - {p.status} + {/* Cookie-auth posts land as 'pending' until the browser + worker publishes and reconciles the real outcome. */} + {p.status === "pending" ? "posting…" : p.status} {p.provider && ( via {p.provider} diff --git a/lib/promote/reconcilePromo.ts b/lib/promote/reconcilePromo.ts new file mode 100644 index 00000000..1f67773f --- /dev/null +++ b/lib/promote/reconcilePromo.ts @@ -0,0 +1,57 @@ +// Reconcile a Promote (promo_post) row once its cookie-auth (browser) post +// settles in the Playwright worker. +// +// Cookie-auth posts are only *enqueued* by the sweep, which records the +// promo_post as 'pending' with a link to the sp_post (sp_post_id). The worker +// is where the post actually publishes or fails, so it's where we flip the +// promo_post to its real status, fill in the public post_url, and refund the +// credit for a post that never landed. No-op for sp_posts that didn't +// originate from Promote (no linked promo_post row). + +import type { SupabaseClient } from "@supabase/supabase-js"; + +export async function reconcilePromo( + supabase: SupabaseClient, + postId: string, + outcome: "posted" | "failed", + opts: { postUrl?: string | null; platformPostId?: string | null; error?: string | null } = {}, +): Promise { + const { data: promo } = await supabase + .from("promo_post") + .select("id, status, credits_spent, promo_list(user_id)") + .eq("sp_post_id", postId) + .maybeSingle(); + if (!promo) return; // not a promote-originated post + + // Only reconcile a still-pending row (keeps this idempotent if it re-runs). + if (promo.status !== "pending") return; + + if (outcome === "posted") { + await supabase + .from("promo_post") + .update({ + status: "posted", + post_url: opts.postUrl || null, + external_post_id: opts.platformPostId || null, + posted_at: new Date().toISOString(), + error: null, + }) + .eq("id", promo.id); + return; + } + + // Failed: refund the up-front credit, then mark failed. Zeroing credits_spent + // keeps the refund idempotent if this somehow re-runs. + const spent = (promo.credits_spent as number | null) ?? 0; + // PostgREST embeds a many-to-one relation as an object (or array in some + // client typings) — normalize. + const list = Array.isArray(promo.promo_list) ? promo.promo_list[0] : promo.promo_list; + const ownerId = (list as { user_id?: string } | null)?.user_id; + if (spent > 0 && ownerId) { + await supabase.rpc("consume_credit", { p_owner: ownerId, p_count: -spent }); + } + await supabase + .from("promo_post") + .update({ status: "failed", error: opts.error ?? null, credits_spent: 0 }) + .eq("id", promo.id); +} diff --git a/lib/promote/sweep.ts b/lib/promote/sweep.ts index 0791b3c9..0a5173f1 100644 --- a/lib/promote/sweep.ts +++ b/lib/promote/sweep.ts @@ -18,6 +18,9 @@ export type PromoteSweepResult = { postsAttempted: number; postsSucceeded: number; postsFailed: number; + // Cookie-auth posts enqueued this sweep but not yet published (reconciled + // later by the worker). + postsPending: number; listsPaused: number; }; @@ -63,6 +66,7 @@ export async function processDuePromoteLists( postsAttempted: 0, postsSucceeded: 0, postsFailed: 0, + postsPending: 0, listsPaused: 0, }; @@ -98,6 +102,7 @@ export async function processDuePromoteLists( result.postsAttempted += r.attempted; result.postsSucceeded += r.succeeded; result.postsFailed += r.failed; + result.postsPending += r.pending; if (r.paused) result.listsPaused++; } catch (err) { console.error( @@ -123,8 +128,8 @@ async function processOneList( supabase: SupabaseClient, list: PromoList, clients: PromoteSweepClients, -): Promise<{ attempted: number; succeeded: number; failed: number; paused: boolean }> { - const out = { attempted: 0, succeeded: 0, failed: 0, paused: false }; +): Promise<{ attempted: number; succeeded: number; failed: number; pending: number; paused: boolean }> { + const out = { attempted: 0, succeeded: 0, failed: 0, pending: 0, paused: false }; // Resolve accounts const accounts = await resolveAccounts(supabase, list); @@ -238,7 +243,12 @@ async function processOneList( source: "manual", // reuse existing source type }); - // Record the promo_post + // Cookie-auth posts (reddit/instagram/mastodon/x/linkedin/facebook/threads) + // are only *enqueued* here — the Playwright worker publishes later. Record + // them as 'pending' with a link to the sp_post so the worker can reconcile + // the real URL/status (and refund on failure). Only synchronous API posts + // (bluesky/telegram/discord + OAuth reddit/mastodon) are 'posted' now. + const isPending = postResult.ok && postResult.pending === true; await supabase.from("promo_post").insert({ list_id: list.id, link_id: targetLink.id, @@ -247,23 +257,27 @@ async function processOneList( body: pitch.body, provider: pitch.provider, model: pitch.model, - status: postResult.ok ? "posted" : "failed", - external_post_id: postResult.ok ? postResult.platformPostId : null, - post_url: postResult.ok ? postResult.webUrl || null : null, + status: !postResult.ok ? "failed" : isPending ? "pending" : "posted", + external_post_id: postResult.ok && !isPending ? postResult.platformPostId : null, + post_url: postResult.ok && !isPending ? postResult.webUrl || null : null, error: postResult.ok ? null : postResult.error, credits_spent: 1, - posted_at: postResult.ok ? new Date().toISOString() : null, + posted_at: postResult.ok && !isPending ? new Date().toISOString() : null, + sp_post_id: postResult.ok && isPending ? postResult.postId : null, }); - if (postResult.ok) { - out.succeeded++; - } else { + if (!postResult.ok) { out.failed++; - // Refund credit on post failure + // Synchronous failure — refund the credit now. (Async cookie failures + // are refunded later by reconcilePromo in the worker.) await supabase.rpc("consume_credit", { p_owner: list.user_id, p_count: -1, }); + } else if (isPending) { + out.pending++; + } else { + out.succeeded++; } } catch (err) { out.failed++; diff --git a/lib/sp/browserPost.ts b/lib/sp/browserPost.ts index 448d4c19..e2e748cc 100644 --- a/lib/sp/browserPost.ts +++ b/lib/sp/browserPost.ts @@ -32,6 +32,7 @@ import { type ImageStylePref, } from "@/lib/sp/imageGen"; import { reconcileOutreach } from "@/lib/sp/outreachReconcile"; +import { reconcilePromo } from "@/lib/promote/reconcilePromo"; import { pickDefaultSubreddit } from "@/lib/sp/redditSubreddit"; import { makeCodeWaiter } from "@/lib/sp/verificationChallenge"; @@ -176,6 +177,10 @@ export async function processBrowserPost(args: { auth_mode: "cookie", }); await reconcileOutreach(supabase, postId, "sent", null); + await reconcilePromo(supabase, postId, "posted", { + postUrl: result.webUrl, + platformPostId: result.platformPostId, + }); console.log(`[browser-post] ${postId} published to ${account.platform} → ${result.webUrl}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -230,4 +235,5 @@ async function fail( auth_mode: "cookie", }); await reconcileOutreach(supabase, postId, "failed", message); + await reconcilePromo(supabase, postId, "failed", { error: message }); } diff --git a/lib/sp/post.ts b/lib/sp/post.ts index 18a9a6b7..dcab8f8b 100644 --- a/lib/sp/post.ts +++ b/lib/sp/post.ts @@ -71,6 +71,11 @@ export type PostOk = { postId: string; webUrl: string; platformPostId: string; + // True when the post was only *enqueued* (cookie-auth → async Playwright + // worker) and hasn't actually published yet. Callers must not treat this as a + // completed post: webUrl/platformPostId are empty and the real outcome is + // reconciled later from the worker. Absent/false = published synchronously. + pending?: boolean; }; export type PostErr = { ok: false; error: string }; export type PostResult = PostOk | PostErr; @@ -137,7 +142,9 @@ export async function postViaAccount(args: { return { ok: false, error: insErr?.message ?? "Could not queue post." }; } await enqueueBrowserPost(row.id); - return { ok: true, postId: row.id, webUrl: "", platformPostId: "" }; + // Only enqueued — the Playwright worker publishes later and the caller + // reconciles the real URL/status then. Not a completed post. + return { ok: true, postId: row.id, webUrl: "", platformPostId: "", pending: true }; } let title: string | null = null; diff --git a/supabase/migrations/20260717140000_promo_post_sp_link.sql b/supabase/migrations/20260717140000_promo_post_sp_link.sql new file mode 100644 index 00000000..a5cf5d97 --- /dev/null +++ b/supabase/migrations/20260717140000_promo_post_sp_link.sql @@ -0,0 +1,14 @@ +-- Link a promo_post to the sp_post it enqueued for cookie-auth (browser) +-- platforms (reddit, instagram, mastodon, x, linkedin, facebook, threads). +-- +-- Those platforms post asynchronously: postViaAccount only *queues* an sp_post +-- and returns before the Playwright worker actually publishes. Until now the +-- sweep marked the promo_post 'posted' at enqueue time with a null post_url — +-- so the "View post" link was missing and a post that later failed (dead +-- cookies / login wall) still read as posted. Storing the sp_post id lets the +-- worker reconcile the real outcome (URL, posted/failed, credit refund) back +-- onto the promo_post once the browser post settles. +alter table public.promo_post + add column if not exists sp_post_id uuid references public.sp_post(id) on delete set null; + +create index if not exists promo_post_sp_post_idx on public.promo_post(sp_post_id); diff --git a/tests/promote/reconcile-promo.test.ts b/tests/promote/reconcile-promo.test.ts new file mode 100644 index 00000000..7eb45f8f --- /dev/null +++ b/tests/promote/reconcile-promo.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { reconcilePromo } from "@/lib/promote/reconcilePromo"; + +// Minimal chainable Supabase stub: select→eq→maybeSingle resolves `promo`; +// update(payload)→eq resolves; rpc records the call. +function makeSupabase(promo: unknown) { + const updates: Record[] = []; + const rpcs: { name: string; args: unknown }[] = []; + const client = { + from() { + return { + select() { + return this; + }, + eq() { + return this; + }, + maybeSingle() { + return Promise.resolve({ data: promo, error: null }); + }, + update(payload: Record) { + updates.push(payload); + return { eq: () => Promise.resolve({ error: null }) }; + }, + }; + }, + rpc(name: string, args: unknown) { + rpcs.push({ name, args }); + return Promise.resolve({ error: null }); + }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { client: client as any, updates, rpcs }; +} + +describe("reconcilePromo", () => { + it("posted → fills the real URL + marks posted, no refund", async () => { + const { client, updates, rpcs } = makeSupabase({ + id: "p1", + status: "pending", + credits_spent: 1, + promo_list: { user_id: "u1" }, + }); + + await reconcilePromo(client, "sp1", "posted", { + postUrl: "https://reddit.com/r/x/comments/abc", + platformPostId: "t3_abc", + }); + + expect(rpcs).toHaveLength(0); + expect(updates).toHaveLength(1); + expect(updates[0]).toMatchObject({ + status: "posted", + post_url: "https://reddit.com/r/x/comments/abc", + external_post_id: "t3_abc", + error: null, + }); + }); + + it("failed → refunds the credit and marks failed", async () => { + const { client, updates, rpcs } = makeSupabase({ + id: "p1", + status: "pending", + credits_spent: 1, + promo_list: { user_id: "u1" }, + }); + + await reconcilePromo(client, "sp1", "failed", { error: "Login wall: session expired" }); + + expect(rpcs).toEqual([{ name: "consume_credit", args: { p_owner: "u1", p_count: -1 } }]); + expect(updates[0]).toMatchObject({ status: "failed", credits_spent: 0, error: "Login wall: session expired" }); + }); + + it("is a no-op when the row is no longer pending (idempotent)", async () => { + const { client, updates, rpcs } = makeSupabase({ + id: "p1", + status: "posted", + credits_spent: 1, + promo_list: { user_id: "u1" }, + }); + + await reconcilePromo(client, "sp1", "failed", { error: "late failure" }); + + expect(updates).toHaveLength(0); + expect(rpcs).toHaveLength(0); + }); + + it("is a no-op for an sp_post with no linked promo_post", async () => { + const { client, updates, rpcs } = makeSupabase(null); + + await reconcilePromo(client, "sp-not-promo", "posted", { postUrl: "https://x" }); + + expect(updates).toHaveLength(0); + expect(rpcs).toHaveLength(0); + }); +}); diff --git a/worker/index.ts b/worker/index.ts index b8a287cc..dd76019d 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1280,7 +1280,7 @@ async function promoteSweep() { const r = await processDuePromoteLists(supabase, { anthropic, openai }); if (r.postsAttempted > 0) { console.log( - `[worker] promote sweep lists=${r.listsProcessed} attempted=${r.postsAttempted} ok=${r.postsSucceeded} fail=${r.postsFailed} paused=${r.listsPaused}`, + `[worker] promote sweep lists=${r.listsProcessed} attempted=${r.postsAttempted} ok=${r.postsSucceeded} pending=${r.postsPending} fail=${r.postsFailed} paused=${r.listsPaused}`, ); } }