/** * Migration — 2026-07-26-fix-quote-counter-and-reassign-numbers.ts * * Background * ---------- * The `quote_counters.last_seq` field for `(year=2026, month=7)` was * seeded with a junk integer (20 260 720 260 710 456 — looks like a * string-concat payload from an old format function). PocketBase's * `last_seq+1` autoincrement happily kept adding 1, but the formatted * quote_number came out as `Q20260720260720260710456`, and large * `last_seq` values got serialised back to the JS client as * `Q2026072.0260720260720263e+22`. * * Two flavours of bad rows exist in `quotes.quote_number`: * - `Q010433`-style legacy 6-digit format (no year/month prefix) * — most rows, from before the counter existed * - `Q2026072.0260720260720263e+22` style — written by the broken * counter, JSON-serialised giant number * * What this migration does * ------------------------ * Default mode (no args, or `--dry-run`) → prints the full before/after * diff and exits without writing. Pass `--apply` to actually write. * * 1. Load every quote, sort by `created` ASC. * 2. Reassign each one a clean, sortable, unique quote_number of the * form `Q{YYYY}{MM}{NNN}` starting at `Q202607001`. * `formatQuoteNumber` (mirrored here) pads `seq` to >= 3 digits so * `seq=1..200` → `001..200`, no truncation. * 3. Update the `quote_counters` row for `(2026, 7)` to `last_seq=200` * so the next call to `/api/v1/quotes/next-number` returns * `Q202607201`. * * Idempotent: rerunning always lands on the same target numbers. * * External refs (email subjects, PDF footers, paste-in-chat) to the * old `Q010433`-style numbers will no longer resolve. */ import PocketBase from "pocketbase"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; // ── env loading ──────────────────────────────────────────────────────── function loadEnvFile(name: string) { try { const raw = readFileSync(resolve(process.cwd(), name), "utf8"); for (const line of raw.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i); if (m && !process.env[m[1]]) { process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim(); } } } catch { /* fine — rely on shell env */ } } loadEnvFile(".env"); loadEnvFile(".env.local"); const URL = process.env.NEXT_PUBLIC_POCKETBASE_URL; const EMAIL = process.env.PB_ADMIN_EMAIL; const PASSWORD = process.env.PB_ADMIN_PASSWORD; if (!URL || !EMAIL || !PASSWORD) { throw new Error( "Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (.env.local).", ); } async function authedPb(): Promise { const pb = new PocketBase(URL!); await pb .collection("_superusers") .authWithPassword(EMAIL!, PASSWORD!); return pb; } // Mirror of src/lib/server/quotes-service.ts — kept inline so the // migration doesn't depend on the Next build output. function formatQuoteNumber(year: number, month: number, seq: number): string { return `Q${year}${String(month).padStart(2, "0")}${String(seq).padStart(3, "0")}`; } type QuoteRec = { id: string; quote_number: string; status: string; created: string; }; async function main() { const apply = process.argv.includes("--apply"); const dryRun = !apply; console.log( dryRun ? "=== DRY RUN — no writes ===\n" : "=== APPLY MODE — live writes ===\n", ); const pb = await authedPb(); // All quotes, sorted by created ASC (oldest first). const all = (await pb .collection("quotes") .getFullList({ sort: "created" })) as QuoteRec[]; console.log(`Loaded ${all.length} quote rows.\n`); // Assign each row a unique target Q{YYYY}{MM}{NNN}, starting at 001. const year = 2026; const month = 7; const usedTargets = new Set(); const changes: Array<{ id: string; created: string; from: string; to: string; }> = []; let seq = 0; // human numbering starts at 1 (seq+1 below) for (const q of all) { seq++; let target = formatQuoteNumber(year, month, seq); // Walk forward if the target slot is already taken (shouldn't happen // on a clean run, but future-reruns / partial-state runs are safe). while (usedTargets.has(target)) { seq++; target = formatQuoteNumber(year, month, seq); } usedTargets.add(target); const from = q.quote_number || ""; if (from === target) continue; // already correct changes.push({ id: q.id, created: q.created, from, to: target }); } console.log("Plan:"); console.log(" Total rows: ", all.length); console.log(" Already-clean: ", all.length - changes.length); console.log(" To update: ", changes.length); console.log(" Next free slot: ", formatQuoteNumber(year, month, seq + 1)); if (changes.length) { console.log("\nFirst 10 changes (old → new):"); changes.slice(0, 10).forEach((c) => console.log( ` ${c.id} ${c.created} ${JSON.stringify(c.from).padEnd(36)} → ${c.to}`, ), ); if (changes.length > 10) console.log(` … and ${changes.length - 10} more`); } // Counter row update const counters = (await pb .collection("quote_counters") .getFullList()) as Array<{ id: string; year: number; month: number; last_seq: unknown; }>; const counterRow = counters.find((c) => c.year === year && c.month === month); // Counter `last_seq` should equal the highest seq we just used. Next // call's `last_seq+1` returns that + 1, formatted as the next slot. const newLastSeq = seq; console.log("\nCounter:"); if (counterRow) { console.log( ` current (year=${year} month=${month}): last_seq = ${JSON.stringify( counterRow.last_seq, )} (id=${counterRow.id})`, ); } else { console.log(` no row for (${year}, ${month}); will create one`); } console.log( ` → planned set last_seq = ${newLastSeq} (next quote = ${formatQuoteNumber( year, month, newLastSeq + 1, )})`, ); if (dryRun) { console.log("\nDry run complete. Re-run with --apply to execute."); return; } // ── APPLY ─────────────────────────────────────────────────────────── console.log("\nApplying updates…"); let updated = 0; for (const c of changes) { try { await pb.collection("quotes").update(c.id, { quote_number: c.to }); updated++; if (updated % 50 === 0) console.log(` …${updated}/${changes.length}`); } catch (e: unknown) { console.error( ` FAILED ${c.id} (${c.from} → ${c.to}): ${(e as Error).message}`, ); } } console.log(`Updated ${updated}/${changes.length} quote rows.`); if (counterRow) { await pb .collection("quote_counters") .update(counterRow.id, { last_seq: newLastSeq }); console.log(`Counter row (${year}-${month}) last_seq → ${newLastSeq}.`); } else { await pb .collection("quote_counters") .create({ year, month, last_seq: newLastSeq }); console.log(`Counter row (${year}-${month}) created with last_seq=${newLastSeq}.`); } console.log("\nDone."); } main().catch((e: unknown) => { console.error("\nMigration failed:", (e as Error).message); process.exit(1); });