/** * Backfill `document_signers` rows for EOI documents that were generated * before the per-recipient signer-row insert landed (pre-2026-05-15). * * Symptom on the affected docs: the EOI tab's "Signing progress" panel * reads "No signers loaded" forever because the webhook handler updates * existing rows (by token / email) and never inserts new ones. * * This script walks every documents row that has a documensoId, status * in ('sent', 'partially_signed', 'completed'), and zero signer rows. * For each, it pulls the envelope from Documenso and recreates the * signer rows from the recipients array. Idempotent — safe to re-run. * * Usage: * pnpm tsx scripts/backfill-eoi-signers.ts # dry-run, lists candidates * pnpm tsx scripts/backfill-eoi-signers.ts --apply # actually inserts */ import 'dotenv/config'; import { and, inArray, isNotNull, sql } from 'drizzle-orm'; import { db, closeDb } from '@/lib/db'; import { documents, documentSigners } from '@/lib/db/schema/documents'; import { getDocument as getDocumensoDoc } from '@/lib/services/documenso-client'; import { logger } from '@/lib/logger'; interface BackfillStats { scanned: number; withZeroSigners: number; inserted: number; failed: number; skipped: number; } async function main() { const apply = process.argv.includes('--apply'); // 1. Find candidate documents: in-flight or completed EOIs with a // documensoId and no signer rows. const candidates = await db .select({ id: documents.id, portId: documents.portId, documensoId: documents.documensoId, status: documents.status, documentType: documents.documentType, title: documents.title, signerCount: sql`( SELECT COUNT(*)::int FROM ${documentSigners} WHERE ${documentSigners.documentId} = ${documents.id} )`, }) .from(documents) .where( and( inArray(documents.status, ['sent', 'partially_signed', 'completed']), isNotNull(documents.documensoId), ), ); const stats: BackfillStats = { scanned: candidates.length, withZeroSigners: 0, inserted: 0, failed: 0, skipped: 0, }; const needsBackfill = candidates.filter((c) => c.signerCount === 0); stats.withZeroSigners = needsBackfill.length; console.log( `Scanned ${stats.scanned} document${stats.scanned === 1 ? '' : 's'}; ${stats.withZeroSigners} need backfill.`, ); if (!apply) { console.log('\nDRY RUN (pass --apply to insert):'); for (const doc of needsBackfill) { console.log(` - ${doc.id} (${doc.title}) — port=${doc.portId}, status=${doc.status}`); } await closeDb(); return; } // 2. For each candidate, fetch the envelope from Documenso and insert // the signer rows. Failures are logged + counted; processing // continues so one broken doc doesn't halt the run. for (const doc of needsBackfill) { if (!doc.documensoId) { stats.skipped++; continue; } try { const envelope = await getDocumensoDoc(doc.documensoId, doc.portId); if (envelope.recipients.length === 0) { logger.warn({ documentId: doc.id }, 'Backfill: envelope has no recipients — skipping'); stats.skipped++; continue; } // Use the same role-mapping logic as the create-time flow: // - signingOrder=1 + role SIGNER → 'client' (positional) // - SIGNER otherwise → 'signer' // - APPROVER → 'approver' // - CC / VIEWER → pass-through const rows = envelope.recipients.map((r) => { const cleanName = (r.name || r.email) .replace(/\s*\(was:[^)]*\)/i, '') .replace(/\s*\(placeholder\)/i, '') .trim(); const upRole = r.role.toUpperCase(); const role = upRole === 'SIGNER' && r.signingOrder === 1 ? 'client' : upRole === 'APPROVER' ? 'approver' : upRole === 'CC' ? 'cc' : upRole === 'VIEWER' ? 'viewer' : 'signer'; return { documentId: doc.id, signerName: cleanName || r.email, signerEmail: r.email, signerRole: role, signingOrder: r.signingOrder, status: (r.status === 'SIGNED' ? 'signed' : 'pending') as 'signed' | 'pending', signingUrl: r.signingUrl ?? null, embeddedUrl: r.embeddedUrl ?? null, signingToken: r.token ?? null, // No invitedAt — the backfill can't reconstruct the original // dispatch timestamp. Reps see the card as "Not yet invited" // for any pending signer; clicking Send invitation re-stamps. invitedAt: null, }; }); await db.insert(documentSigners).values(rows); stats.inserted += rows.length; console.log(` ✓ ${doc.id} (${doc.title}) — inserted ${rows.length} signer row(s)`); } catch (err) { stats.failed++; logger.error( { err: err instanceof Error ? err.message : err, documentId: doc.id }, 'Backfill failed for document', ); console.log(` ✗ ${doc.id} — ${err instanceof Error ? err.message : 'unknown error'}`); } } console.log(`\nDone. inserted=${stats.inserted} failed=${stats.failed} skipped=${stats.skipped}`); await closeDb(); } main().catch((err) => { console.error(err); process.exit(1); });