- 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
237 lines
No EOL
8.5 KiB
JavaScript
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);
|
|
}); |