diff --git a/src/app/api/cron/backfill/route.ts b/src/app/api/cron/backfill/route.ts index 955a5cc9..a87a26db 100644 --- a/src/app/api/cron/backfill/route.ts +++ b/src/app/api/cron/backfill/route.ts @@ -307,6 +307,9 @@ async function processStorylineCreated( throw new Error(`Database error (genesis plot): ${plotError.message}`); } + // Reconcile plot_count from actual plots rows (prevents genesis double-count) + await reconcileStorylinePlotCount(supabase, Number(storylineId)); + return { genesisPlotFailed: false }; } diff --git a/src/app/api/cron/reconcile-plots/route.ts b/src/app/api/cron/reconcile-plots/route.ts new file mode 100644 index 00000000..6a6125f3 --- /dev/null +++ b/src/app/api/cron/reconcile-plots/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { createServerClient } from "../../../../../lib/supabase"; +import { reconcileStorylinePlotCount } from "../../../../../lib/reconcile"; +import { STORY_FACTORY } from "../../../../../lib/contracts/constants"; + +/** + * One-time reconciliation endpoint: re-counts plot_count for ALL storylines + * from the plots table. Safe to run multiple times (idempotent). + * + * POST /api/cron/reconcile-plots + */ +/** Cron authorization — fail closed in production when CRON_SECRET is unset */ +function verifyCron(req: Request): boolean { + const secret = process.env.CRON_SECRET; + if (!secret) { + return process.env.NODE_ENV !== "production"; + } + const authHeader = req.headers.get("authorization"); + return authHeader === `Bearer ${secret}`; +} + +export async function POST(req: Request) { + if (!verifyCron(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const supabase = createServerClient(); + if (!supabase) { + return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); + } + + // Fetch all storyline IDs + const { data: storylines, error: fetchError } = await supabase + .from("storylines") + .select("storyline_id") + .eq("contract_address", STORY_FACTORY.toLowerCase()); + + if (fetchError) { + return NextResponse.json({ error: fetchError.message }, { status: 500 }); + } + + if (!storylines || storylines.length === 0) { + return NextResponse.json({ reconciled: 0 }); + } + + let reconciled = 0; + const errors: string[] = []; + + for (const s of storylines) { + try { + await reconcileStorylinePlotCount(supabase, s.storyline_id); + reconciled++; + } catch (err) { + errors.push(`storyline ${s.storyline_id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + return NextResponse.json({ reconciled, total: storylines.length, errors }); +} diff --git a/src/app/api/index/storyline/route.ts b/src/app/api/index/storyline/route.ts index 1fb9e3e2..27aad5ef 100644 --- a/src/app/api/index/storyline/route.ts +++ b/src/app/api/index/storyline/route.ts @@ -12,6 +12,7 @@ import { detectWriterType } from "../../../../../lib/contracts/erc8004"; import { hashContent } from "../../../../../lib/content"; import { GENRES, LANGUAGES } from "../../../../../lib/genres"; import type { Database } from "../../../../../lib/supabase"; +import { reconcileStorylinePlotCount } from "../../../../../lib/reconcile"; const IPFS_GATEWAY = "https://ipfs.filebase.io/ipfs/"; const IPFS_TIMEOUT_MS = 10_000; @@ -179,5 +180,8 @@ export async function POST(req: Request) { return error(`Database error (genesis plot): ${plotDbError.message}`, 500); } + // Reconcile plot_count from actual plots rows (prevents genesis double-count) + await reconcileStorylinePlotCount(supabase, Number(storylineId)); + return NextResponse.json({ success: true }); }