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}`,
);
}
}