- Update sprint page query to only show unprocessed items from active (new/claimed) quotes, plus orphans - Add one-shot migration to mark existing stale unprocessed quote items as processed - Add one-shot migration to fix duplicate Q202608235 quote number entries - Refine comments in the sprint page component for better maintainability
288 lines
No EOL
9.8 KiB
JavaScript
288 lines
No EOL
9.8 KiB
JavaScript
/* eslint-disable */
|
|
/**
|
|
* One-shot migration — 2026-08-07-renumber-duplicate-quotes.js
|
|
*
|
|
* Background
|
|
* ----------
|
|
* As of 2026-08-07, 39 quote records share `quote_number = "Q202608235"`.
|
|
* The atomic `quote_counters` row for (2026, 8) had been reset to a low
|
|
* value (likely by `scripts/setup-pocketbase.ts` wiping the collection),
|
|
* so subsequent creations kept getting fresh numbers — but the 39
|
|
* pre-existing duplicates were never re-stamped.
|
|
*
|
|
* What this script does
|
|
* ---------------------
|
|
* Default mode (no args, or `--dry-run`) → reads counter state, projects
|
|
* the next 38 reserved numbers, prints the full before/after plan, and
|
|
* exits without writing. Dry-run does NOT consume counter values, so
|
|
* the plan you see IS the plan that apply will execute.
|
|
*
|
|
* Pass `--apply` to write.
|
|
*
|
|
* 1. Find every quote with `quote_number = "Q202608235"` AND
|
|
* `status != "new"`. The single `status = "new"` row is the
|
|
* legitimate Q202608235 (the actual Hennie Muller / Gary Isherwood
|
|
* RFQ that arrived at 2026-08-07). We keep that one untouched and
|
|
* renumber the other 38.
|
|
*
|
|
* 2. Project the next 38 quote numbers from `quote_counters.last_seq`
|
|
* WITHOUT incrementing (dry-run) or atomically reserve them (apply,
|
|
* via `last_seq+` operator — same pattern
|
|
* `src/lib/server/quotes-service.ts:generateQuoteNumberServer` uses).
|
|
*
|
|
* 3. Update the quote row + every `quote_items` row whose `quote` FK
|
|
* points at it. `quote_items.quote_number` is a denormalised text
|
|
* field that the sprint page reads for display; if we don't update
|
|
* it, the sprint view keeps showing "Q202608235" on rows that now
|
|
* belong to a different parent.
|
|
*
|
|
* 4. Leaves the `quote_counters.last_seq` advanced by exactly 38.
|
|
*
|
|
* Idempotent: if run a second time, the 38 quotes no longer have
|
|
* quote_number "Q202608235" so the script finds zero matches and exits.
|
|
*/
|
|
const PocketBase = require("pocketbase/cjs");
|
|
const { readFileSync } = require("node:fs");
|
|
const { resolve } = require("node:path");
|
|
|
|
function loadEnvFile(name) {
|
|
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*=(.+)$/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)."
|
|
);
|
|
}
|
|
|
|
const DUP_QUOTE_NUMBER = "Q202608235";
|
|
|
|
function formatQuoteNumber(year, month, seq) {
|
|
return `Q${year}${String(month).padStart(2, "0")}${String(seq).padStart(3, "0")}`;
|
|
}
|
|
|
|
async function authedPb() {
|
|
const pb = new PocketBase(URL);
|
|
await pb.collection("_superusers").authWithPassword(EMAIL, PASSWORD);
|
|
return pb;
|
|
}
|
|
|
|
/**
|
|
* Get the (year, month) counter row, creating it on first use.
|
|
* Same create-or-fetch + backfill dance as
|
|
* `getOrCreateCounter` in src/lib/server/quotes-service.ts,
|
|
* inlined here so this migration script has no @/lib dependencies.
|
|
*/
|
|
async function getOrCreateCounter(pb, year, month) {
|
|
let counter;
|
|
try {
|
|
counter = await pb
|
|
.collection("quote_counters")
|
|
.getFirstListItem(`year=${year} && month=${month}`);
|
|
return counter;
|
|
} catch {
|
|
/* fall through to create */
|
|
}
|
|
|
|
let maxSeq = 0;
|
|
try {
|
|
const all = await pb.collection("quotes").getFullList({
|
|
fields: "quote_number",
|
|
batch: 5000,
|
|
});
|
|
const reNoDash = new RegExp(`^Q${year}${String(month).padStart(2, "0")}(\\d+)$`);
|
|
const reDashed = new RegExp(`^Q-${year}-${String(month).padStart(2, "0")}-(\\d+)$`);
|
|
for (const q of all) {
|
|
const num = q.quote_number || "";
|
|
const m = num.match(reNoDash) || num.match(reDashed);
|
|
if (m) {
|
|
const n = parseInt(m[1], 10);
|
|
if (Number.isFinite(n) && n > maxSeq) maxSeq = n;
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore — start from 0 */
|
|
}
|
|
|
|
try {
|
|
counter = await pb.collection("quote_counters").create({
|
|
year,
|
|
month,
|
|
last_seq: maxSeq,
|
|
});
|
|
} catch {
|
|
// Lost a race — another process created the row first. Fetch theirs.
|
|
counter = await pb
|
|
.collection("quote_counters")
|
|
.getFirstListItem(`year=${year} && month=${month}`);
|
|
}
|
|
return counter;
|
|
}
|
|
|
|
/**
|
|
* Atomically reserve the next quote number for (year, month).
|
|
* Same pattern as `generateQuoteNumberServer` in
|
|
* src/lib/server/quotes-service.ts.
|
|
*/
|
|
async function reserveNext(pb, year, month) {
|
|
const counter = await getOrCreateCounter(pb, year, month);
|
|
const updated = await pb
|
|
.collection("quote_counters")
|
|
.update(counter.id, { "last_seq+": 1 });
|
|
return formatQuoteNumber(year, month, updated.last_seq);
|
|
}
|
|
|
|
async function main() {
|
|
const apply = process.argv.includes("--apply");
|
|
const dryRun = !apply;
|
|
console.log(
|
|
dryRun
|
|
? "=== DRY RUN — no writes, counter NOT consumed ===\n"
|
|
: "=== APPLY MODE — live writes, counter will advance ===\n"
|
|
);
|
|
|
|
const pb = await authedPb();
|
|
console.log(`Authenticated against ${URL}\n`);
|
|
|
|
// 1. Find the duplicates. We expect 39 rows; the single `status = "new"`
|
|
// one is the legitimate Q202608235 and stays as-is.
|
|
const dups = await pb.collection("quotes").getFullList({
|
|
filter: `quote_number = "${DUP_QUOTE_NUMBER}"`,
|
|
sort: "created",
|
|
fields: "id,status,customer,quote_number,created,email_subject",
|
|
});
|
|
console.log(`Quotes stamped ${DUP_QUOTE_NUMBER}: ${dups.length}`);
|
|
|
|
const toRenumber = dups.filter((q) => q.status !== "new");
|
|
const keep = dups.filter((q) => q.status === "new");
|
|
console.log(` → ${keep.length} legitimate (status=new) — keep as-is`);
|
|
console.log(` → ${toRenumber.length} duplicates — ${apply ? "renumber" : "would renumber"}\n`);
|
|
|
|
if (toRenumber.length === 0) {
|
|
console.log("Nothing to do. (Already migrated?)");
|
|
return;
|
|
}
|
|
|
|
// 2. Read the current counter state WITHOUT consuming it. The dry-run
|
|
// plan below is a pure projection of what apply would reserve. The
|
|
// actual increments happen in apply via `last_seq+`.
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = now.getMonth() + 1;
|
|
const counter = await getOrCreateCounter(pb, year, month);
|
|
const startSeq = counter.last_seq + 1;
|
|
const endSeq = counter.last_seq + toRenumber.length;
|
|
console.log(
|
|
`Counter state (year=${year}, month=${month}): last_seq=${counter.last_seq}`
|
|
);
|
|
console.log(
|
|
`Projected reservation: ${formatQuoteNumber(year, month, startSeq)} through ${formatQuoteNumber(year, month, endSeq)}\n`
|
|
);
|
|
|
|
const plan = toRenumber.map((q, i) => ({
|
|
id: q.id,
|
|
from: DUP_QUOTE_NUMBER,
|
|
to: formatQuoteNumber(year, month, startSeq + i),
|
|
status: q.status,
|
|
created: q.created,
|
|
subject: (q.email_subject || "").slice(0, 60),
|
|
}));
|
|
|
|
// 3. Cross-check: no plan row's `to` collides with any existing quote
|
|
// number other than the dup we're fixing.
|
|
const existing = new Set();
|
|
const allQuotes = await pb.collection("quotes").getFullList({
|
|
fields: "quote_number",
|
|
batch: 5000,
|
|
});
|
|
for (const q of allQuotes) existing.add(q.quote_number || "");
|
|
const clashes = plan.filter(
|
|
(p) => existing.has(p.to) && p.to !== DUP_QUOTE_NUMBER,
|
|
);
|
|
if (clashes.length) {
|
|
console.error(
|
|
`ERROR: ${clashes.length} projected numbers already exist in DB — aborting.`,
|
|
);
|
|
for (const c of clashes) console.error(` ${c.to} ← ${c.id} (${c.subject})`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log("Full plan (in order):");
|
|
plan.forEach((p) =>
|
|
console.log(
|
|
` ${p.id} ${p.created} ${p.status.padEnd(18)} ${p.from} → ${p.to} ${JSON.stringify(p.subject)}`,
|
|
),
|
|
);
|
|
|
|
if (dryRun) {
|
|
console.log("\nDry run complete. Counter NOT advanced. Re-run with --apply to execute.");
|
|
return;
|
|
}
|
|
|
|
// 4. APPLY — atomically reserve each number (via `last_seq+`) and write.
|
|
// We re-reserve here rather than trusting the dry-run projection,
|
|
// so concurrent callers between dry-run and apply can't sneak a
|
|
// number in and steal our slot.
|
|
console.log("\nApplying updates…");
|
|
let updatedQuotes = 0;
|
|
let updatedItems = 0;
|
|
for (let i = 0; i < plan.length; i++) {
|
|
const p = plan[i];
|
|
const reserved = await reserveNext(pb, year, month);
|
|
if (reserved !== p.to) {
|
|
// Concurrent reservation between dry-run and apply shifted us.
|
|
// Use what we actually got — log the divergence so the operator
|
|
// can see what happened.
|
|
console.warn(
|
|
` drift: expected ${p.to}, got ${reserved} (id=${p.id}) — using ${reserved}`,
|
|
);
|
|
p.to = reserved;
|
|
}
|
|
try {
|
|
await pb.collection("quotes").update(p.id, { quote_number: p.to });
|
|
updatedQuotes++;
|
|
} catch (e) {
|
|
console.error(` FAILED quote ${p.id}: ${e.message}`);
|
|
continue;
|
|
}
|
|
try {
|
|
const items = await pb.collection("quote_items").getFullList({
|
|
filter: `quote = "${p.id}"`,
|
|
fields: "id",
|
|
});
|
|
for (const it of items) {
|
|
await pb.collection("quote_items").update(it.id, { quote_number: p.to });
|
|
updatedItems++;
|
|
}
|
|
} catch (e) {
|
|
console.error(` FAILED items cascade for ${p.id}: ${e.message}`);
|
|
}
|
|
if (updatedQuotes % 10 === 0) console.log(` …${updatedQuotes}/${plan.length} quotes`);
|
|
}
|
|
console.log(
|
|
`\nDone. Updated ${updatedQuotes} quote rows and ${updatedItems} quote_items rows.`,
|
|
);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("Migration failed:", e.message);
|
|
console.error(e.stack);
|
|
process.exit(1);
|
|
}); |