From ba47b3e8f198a2a101c0bc21eae9019cdb7fc8b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 20:46:11 +0000 Subject: [PATCH] fix(bulk-upload): re-run existing personal drafts in place instead of duplicating When a submitted URL already had a 'personal' draft bundle, bulk-upload treated it as re-generatable and INSERTed a second bundle row, piling up duplicate drafts on every run. Published/pending_review URLs were skipped, but personal drafts were not. Now a personal draft is re-run in place: the job is enqueued with targetBundleId set so the pipeline UPDATEs the existing draft. If several stale drafts exist for one normalized URL, the newest is targeted and the older ones are archived. Published/pending_review still skip; flagged/ rejected/archived keep prior behavior. author-design-md: gate the 'preserve status on re-run' rule on isRerun && !autoPublish. Editor re-runs (always autoPublish=false) keep preserving curated status; bulk-upload re-runs (autoPublish=true) of a personal draft re-evaluate promotion like a fresh generation, so a refreshed draft can publish instead of staying personal forever. https://claude.ai/code/session_01DrHoAz6ZpPWURR9m4eNU6s --- src/app/api/admin/bulk-upload/route.ts | 78 +++++++++++++++++++++++--- src/lib/generator/author-design-md.ts | 15 +++-- 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/src/app/api/admin/bulk-upload/route.ts b/src/app/api/admin/bulk-upload/route.ts index 7430fb0..82af45c 100644 --- a/src/app/api/admin/bulk-upload/route.ts +++ b/src/app/api/admin/bulk-upload/route.ts @@ -16,6 +16,12 @@ * different URL * - URLs that already have a queued/running job * + * Re-runs in place (instead of inserting a duplicate): + * - URLs whose normalized form already has a personal (draft) bundle. The job + * is enqueued with targetBundleId set so the pipeline UPDATEs that draft + * rather than creating a second one. If several stale drafts exist for the + * same URL, the most-recently-updated is targeted and the rest are archived. + * * Body: { urls: string[] } (max 150) * Response (202): { batchId, enqueued, skipped, outcomes, etaSeconds } */ @@ -102,10 +108,13 @@ export async function POST(req: NextRequest) { ); } - // ── 2. Check for existing bundles (published or pending_review only) ───────── - // Match single-generate's behaviour: personal and archived bundles can be - // re-generated. Blocking on ANY status was the root cause of URLs that - // succeeded via single generate being permanently rejected here. + // ── 2. Check for existing bundles by normalized URL ────────────────────── + // published / pending_review → skip (already in the library or queue). + // personal (draft) → re-run in place: target the existing draft so the + // pipeline UPDATEs it instead of inserting a duplicate. Blocking personal + // drafts outright was the root cause of URLs that succeeded via single + // generate being permanently rejected here; re-running avoids that while + // also avoiding duplicate draft rows. const normalizedList = candidates.map((c) => c.normalized); // Kick off best-effort brand-name discovery in parallel with the DB lookups @@ -114,17 +123,46 @@ export async function POST(req: NextRequest) { const namesPromise = prefetchBrandNames(normalizedList); const existingBundles = await db - .select({ sourceUrlNormalized: bundles.sourceUrlNormalized }) + .select({ + id: bundles.id, + sourceUrlNormalized: bundles.sourceUrlNormalized, + status: bundles.status, + updatedAt: bundles.updatedAt, + }) .from(bundles) .where( and( inArray(bundles.sourceUrlNormalized, normalizedList), - inArray(bundles.status, ['published', 'pending_review']), + inArray(bundles.status, ['published', 'pending_review', 'personal']), ), ); - const existingNormalized = new Set( - existingBundles.map((b) => b.sourceUrlNormalized).filter(Boolean) as string[], - ); + + // URLs that already have a published/pending_review bundle are skipped. + const existingNormalized = new Set(); + // URLs whose only existing bundle(s) are personal drafts are re-run in place. + // Group the drafts per normalized URL so we can target the newest and archive + // any older duplicates. + const draftsByUrl = new Map(); + for (const b of existingBundles) { + if (!b.sourceUrlNormalized) continue; + if (b.status === 'published' || b.status === 'pending_review') { + existingNormalized.add(b.sourceUrlNormalized); + } else if (b.status === 'personal') { + const arr = draftsByUrl.get(b.sourceUrlNormalized) ?? []; + arr.push({ id: b.id, updatedAt: b.updatedAt }); + draftsByUrl.set(b.sourceUrlNormalized, arr); + } + } + + // Build re-run targets only for URLs not already covered by a + // published/pending_review bundle (those skip outright). + const rerunTargets = new Map(); + for (const [norm, drafts] of draftsByUrl) { + if (existingNormalized.has(norm)) continue; + drafts.sort((a, b) => (b.updatedAt?.getTime() ?? 0) - (a.updatedAt?.getTime() ?? 0)); + const [target, ...extras] = drafts; + rerunTargets.set(norm, { targetId: target.id, extraIds: extras.map((e) => e.id) }); + } // ── 3. Check for in-flight jobs ─────────────────────────────────────────── // Scoped to the editor's own jobs (mirrors single-generate). A global check @@ -213,6 +251,25 @@ export async function POST(req: NextRequest) { // ── 5. Insert all jobs sharing one batchId, then dispatch up to the cap ─── const batchId = crypto.randomUUID(); + // Before enqueueing, archive any older duplicate drafts for the URLs we're + // about to re-run, so re-running collapses the pile-up to a single bundle. + // Only touches extras for candidates we actually enqueue (not skipped ones). + const draftExtraIds = toEnqueue.flatMap( + (c) => rerunTargets.get(c.normalized)?.extraIds ?? [], + ); + if (draftExtraIds.length > 0) { + try { + await db + .update(bundles) + .set({ status: 'archived', updatedAt: new Date() }) + .where(inArray(bundles.id, draftExtraIds)); + } catch (err) { + // Non-fatal: re-run still targets the newest draft; the extras just + // linger as drafts. Don't fail the batch over cleanup. + console.warn('[bulk-upload] failed to archive duplicate drafts:', err); + } + } + const insertedJobs = await db .insert(generationJobs) .values( @@ -224,6 +281,9 @@ export async function POST(req: NextRequest) { currentStep: 'queued', userId: editor.id, autoPublish: true, + // When a personal draft already exists, target it so the pipeline + // updates that bundle in place instead of inserting a duplicate. + targetBundleId: rerunTargets.get(c.normalized)?.targetId ?? null, batchId, })), ) diff --git a/src/lib/generator/author-design-md.ts b/src/lib/generator/author-design-md.ts index a6b1140..026ee35 100644 --- a/src/lib/generator/author-design-md.ts +++ b/src/lib/generator/author-design-md.ts @@ -65,6 +65,12 @@ export async function runAuthorDesignMd(payload: AuthorDesignMdPayload): Promise const batchId = jobRow.batchId; const autoPublish = Boolean(jobRow.autoPublish); const isRerun = Boolean(jobRow.targetBundleId); + // Editor-initiated re-runs (rerun-pipeline / bulk-rerun) are always + // autoPublish=false and should preserve the bundle's curated status. A + // bulk-upload re-run that lands on an existing personal draft is + // autoPublish=true and must be allowed to promote like a fresh generation, + // otherwise the draft would be refreshed but stay personal forever. + const preserveStatus = isRerun && !autoPublish; const url = jobRow.url; const editorUserId: string | null = autoPublish ? (jobRow.userId ?? null) : null; @@ -137,8 +143,9 @@ export async function runAuthorDesignMd(payload: AuthorDesignMdPayload): Promise // Quality-gated promotion. Bundles with errors OR overall score < 40 // stay personal so the editor queue isn't polluted with low-quality - // drafts. Re-run mode preserves the existing status (the editor - // already curated this bundle). + // drafts. Editor re-runs (preserveStatus) keep the existing status — the + // editor already curated this bundle; bulk-upload re-runs of a personal + // draft re-evaluate promotion like a fresh generation. const shouldPromote = lintSummary.counts.errors === 0 && coverage.overall >= 40; // Auto-publish jobs (bulk-upload and editor-initiated generations) publish @@ -164,7 +171,7 @@ export async function runAuthorDesignMd(payload: AuthorDesignMdPayload): Promise coverageDosDonts: coverage.dosDonts, reviewNotes: renderLintSummary(lintSummary), accessibilityNotes: renderAccessibilityAdvisory(lintSummary), - ...(isRerun + ...(preserveStatus ? {} : meetsAutoPublishBar ? { @@ -195,7 +202,7 @@ export async function runAuthorDesignMd(payload: AuthorDesignMdPayload): Promise .update(generationJobs) .set({ status: 'completed', - currentStep: isRerun + currentStep: preserveStatus ? 'rerun_complete' : meetsAutoPublishBar ? 'published'