WFDASH-WEBAPP/scripts/migrations/2026-08-07-flip-stale-unprocessed-items.js
SJCoetz b52de77f8c fix(sprint): filter stale quote items, add cleanup migrations
- 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
2026-08-07 20:15:46 +02:00

237 lines
No EOL
8.5 KiB
JavaScript

/* eslint-disable */
/**
* One-shot cleanup — 2026-08-07-flip-stale-unprocessed-items.js
*
* Background
* ----------
* As of 2026-08-07, the /sprint page showed 58 "unprocessed quote items"
* while the /quotes list showed only 3 quotes in the "New" tab. The
* gap (53 items) was real data, not a render bug: every one of the 53
* belonged to a parent quote that an operator had already explicitly
* marked `cancelled`. The child rows kept `is_processed = false`,
* which is the only thing the sprint query used to filter on.
*
* The sprint query has now been tightened
* (`src/app/(app)/sprint/page.tsx`) to also require the parent quote's
* status be `new` or `claimed`, so future cancellations drop their
* children automatically. This script cleans up the rows that were
* already in the inconsistent state — flipping them to
* `is_processed = true` so they no longer surface anywhere in the
* operator UI.
*
* What this script does
* ---------------------
* Default mode (no args) → DRY RUN. Lists every quote_item that
* `is_processed = false` AND parent `quote.status` is NOT in
* (`new`, `claimed`). Prints a per-parent breakdown. No writes.
*
* Pass `--apply` to write.
*
* 1. Fetch every unprocessed quote_item in ONE round-trip (single
* `getFullList` with batch=5000). This is the bottleneck avoided
* by the earlier per-parent iteration pattern — ~8.5k parent
* quotes make that pattern timeout-bound.
* 2. Group by parent quote id; collect the distinct parent ids.
* 3. Fetch only those parents (one round-trip with batch=5000).
* 4. Drop items whose parent status IS `new`/`claimed` (they're
* legitimately unprocessed and the sprint page should keep
* showing them). Keep items whose parent status is anything else.
* 5. Print a per-parent-status breakdown. With --apply, flip each
* kept item to `is_processed = true`.
*
* Why this is a one-shot and not part of the cancel endpoint:
* The data drift happened organically over time; this script catches
* the backlog. Going forward the new sprint filter is the
* defence — even if a future cancel handler forgets to flip items,
* the sprint page won't show them.
*/
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 ACTIONABLE_STATUSES = ["new", "claimed"];
async function authedPb() {
const pb = new PocketBase(URL);
await pb.collection("_superusers").authWithPassword(EMAIL, PASSWORD);
return pb;
}
async function main() {
const apply = process.argv.includes("--apply");
console.log(
apply
? "=== APPLY MODE — live writes (is_processed=true) ===\n"
: "=== DRY RUN — no writes ===\n"
);
const pb = await authedPb();
console.log(`Authenticated against ${URL}\n`);
// 1. Pull every unprocessed item in ONE round-trip. Two queries
// beats the earlier per-parent iteration pattern by ~3 orders of
// magnitude on collections with thousands of parents.
const allUnprocessed = await pb.collection("quote_items").getFullList({
filter: "is_processed = false",
fields: "id,quote",
batch: 5000,
});
console.log(`Unprocessed quote_items (total): ${allUnprocessed.length}`);
// 2. Group by parent. Orphans (empty/null quote) get bucketed
// under a sentinel id so the breakdown still shows them.
const byParent = new Map();
for (const it of allUnprocessed) {
const key = it.quote || "__orphan__";
if (!byParent.has(key)) byParent.set(key, []);
byParent.get(key).push(it.id);
}
const parentIds = [...byParent.keys()].filter((k) => k !== "__orphan__");
console.log(`Distinct parent quote ids: ${parentIds.length}`);
console.log(`Orphan items (no parent quote): ${byParent.get("__orphan__")?.length ?? 0}`);
// 3. Fetch only the parents we care about (one round-trip).
// Quote id list chunked defensively in case PB has an IN-clause
// length cap; the quote table is small enough that one chunk
// suffices.
const parents = await pb.collection("quotes").getFullList({
filter: parentIds.map((id) => `id = "${id}"`).join(" || "),
fields: "id,quote_number,status,email_subject",
batch: 5000,
});
const parentById = new Map(parents.map((p) => [p.id, p]));
// 4. Drop items whose parent is actionable (`new` / `claimed`) —
// those are the legitimately-pending sprint rows that must NOT
// be touched. Everything else goes into the plan.
const plan = [];
let skippedActionable = 0;
for (const [parentId, itemIds] of byParent) {
if (parentId === "__orphan__") {
// Defensive: surface orphans so the operator can clean them up
// manually rather than silently flipping them.
plan.push(...itemIds.map((id) => ({
item_id: id,
parent_id: null,
parent_status: "(orphan)",
parent_quote_number: "—",
})));
continue;
}
const parent = parentById.get(parentId);
if (!parent) {
console.warn(
` WARN: item references missing parent ${parentId} — skipping`,
);
continue;
}
if (ACTIONABLE_STATUSES.includes(parent.status)) {
skippedActionable += itemIds.length;
continue;
}
plan.push(
...itemIds.map((id) => ({
item_id: id,
parent_id: parent.id,
parent_status: parent.status,
parent_quote_number: parent.quote_number,
})),
);
}
// 5. Per-status breakdown for the dry-run summary.
const byStatus = new Map();
for (const p of plan) {
if (!byStatus.has(p.parent_status))
byStatus.set(p.parent_status, { items: 0, parents: new Set() });
const agg = byStatus.get(p.parent_status);
agg.items += 1;
if (p.parent_id) agg.parents.add(p.parent_id);
}
console.log(`\nPlan (items that will flip to is_processed=true):`);
for (const [status, agg] of [...byStatus.entries()].sort((a, b) =>
b[1].items - a[1].items,
)) {
console.log(
` ${status.padEnd(20)} parents=${String(agg.parents.size).padStart(4)} items=${agg.items}`,
);
}
console.log(
` ${"TOTAL".padEnd(20)} parents=${String(new Set(plan.filter((p) => p.parent_id).map((p) => p.parent_id)).size).padStart(4)} items=${plan.length}`,
);
console.log(`\nSkipped (parent status is new/claimed — keep unprocessed): ${skippedActionable}`);
if (plan.length === 0) {
console.log("\nNothing to flip. Re-run with --apply anyway is a no-op.");
return;
}
// 6. Sample first 10 plan rows for visibility.
console.log("\nFirst 10 plan rows:");
for (const p of plan.slice(0, 10)) {
console.log(
` ${p.item_id} parent=${p.parent_id ?? "(none)"} status=${p.parent_status.padEnd(16)} ${p.parent_quote_number}`,
);
}
if (plan.length > 10) console.log(` … and ${plan.length - 10} more`);
if (!apply) {
console.log("\nDry run complete. Re-run with --apply to execute.");
return;
}
// 7. APPLY — flip is_processed=true on every item in the plan.
// PB serialises concurrent updates on the same row, so two
// parallel runs of this script are safe; the `is_processed=true`
// no-op the second time around makes it idempotent.
console.log("\nApplying updates…");
let updated = 0;
let failed = 0;
for (const p of plan) {
try {
await pb.collection("quote_items").update(p.item_id, { is_processed: true });
updated += 1;
} catch (e) {
failed += 1;
console.error(` FAILED ${p.item_id}: ${e.message}`);
}
if (updated % 20 === 0 && updated > 0)
console.log(` …${updated}/${plan.length}`);
}
console.log(
`\nDone. Updated ${updated} items, ${failed} failures. Re-run without --apply for verification (should be 0 plan rows).`,
);
}
main().catch((e) => {
console.error("Migration failed:", e.message);
console.error(e.stack);
process.exit(1);
});