Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/(app)/promote/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
</span>
{p.provider && (
<span className="text-xs text-[var(--color-muted)]">via {p.provider}</span>
Expand Down
57 changes: 57 additions & 0 deletions lib/promote/reconcilePromo.ts
Original file line number Diff line number Diff line change
@@ -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<any>,
postId: string,
outcome: "posted" | "failed",
opts: { postUrl?: string | null; platformPostId?: string | null; error?: string | null } = {},
): Promise<void> {
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);
}
36 changes: 25 additions & 11 deletions lib/promote/sweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -63,6 +66,7 @@ export async function processDuePromoteLists(
postsAttempted: 0,
postsSucceeded: 0,
postsFailed: 0,
postsPending: 0,
listsPaused: 0,
};

Expand Down Expand Up @@ -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(
Expand All @@ -123,8 +128,8 @@ async function processOneList(
supabase: SupabaseClient<any>,
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);
Expand Down Expand Up @@ -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,
Expand All @@ -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++;
Expand Down
6 changes: 6 additions & 0 deletions lib/sp/browserPost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -230,4 +235,5 @@ async function fail(
auth_mode: "cookie",
});
await reconcileOutreach(supabase, postId, "failed", message);
await reconcilePromo(supabase, postId, "failed", { error: message });
}
9 changes: 8 additions & 1 deletion lib/sp/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions supabase/migrations/20260717140000_promo_post_sp_link.sql
Original file line number Diff line number Diff line change
@@ -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);
96 changes: 96 additions & 0 deletions tests/promote/reconcile-promo.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[] = [];
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<string, unknown>) {
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);
});
});
2 changes: 1 addition & 1 deletion worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
);
}
}
Expand Down
Loading