WFDASH-WEBAPP/scripts/2026-08-21-backfill-order-duplicates.ts

116 lines
4 KiB
TypeScript
Raw Permalink Normal View History

/**
* Backfill: cancel existing duplicate orders so the UNIQUE index
* on `orders.email_message_id` can be created.
*
* Context
* ───────
* The 2026-08-21 order-pipeline investigation (commit fb28376) found
* that 84.6% of auto-created orders are duplicates of the same email.
* One worst case: a single Master Drilling email produced
* 138 duplicate orders, one every 5 minutes for hours.
*
* Cause: the IMAP poll keeps returning the same email every 5 minutes,
* and the order-creation flow has no `email_message_id` dedup check.
* The deploy on 2026-08-21 added the dedup check (n8n + service
* layer), but the 423+ existing duplicates are still in the DB.
*
* What this script does
* ─────────────────────
* 1. Calls `findDuplicateOrders()` to enumerate every duplicate group
* that's still "on the board" (has at least one active item).
* 2. For each group, keeps the OLDEST record (first created) and
* cancels the rest via `cancelOrder({ allowDelivered: true })`.
* 3. Logs the outcome per group.
*
* Operators can run the dedicated `/orders` "Find duplicates" dialog
* to review the per-item details before cancelling; this script is
* the "I trust the data, just clean it up" path.
*
* Idempotent: re-running produces no further changes once every
* duplicate group has been reduced to one record.
*
* Run with:
* npx tsx --env-file=.env.local scripts/2026-08-21-backfill-order-duplicates.ts
*
* Requires .env.local with NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL,
* PB_ADMIN_PASSWORD.
*/
import "./_env"; // side-effect import: loads .env.local before anything else
import {
findDuplicateOrders,
cancelOrder,
} from "../src/lib/server/orders-service";
const DRY_RUN = process.argv.includes("--dry-run");
async function main() {
console.log("Finding duplicate orders (active only)...");
const groups = await findDuplicateOrders();
console.log(`Found ${groups.length} duplicate groups.`);
if (DRY_RUN) {
console.log("\n[dry-run] would cancel the following:");
for (const g of groups) {
const sorted = [...g.records].sort((a, b) => a.created.localeCompare(b.created));
const keep = sorted[0];
const cancel = sorted.slice(1);
console.log(
` group ${g.kind}="${g.orderNumber}" keep=${keep.id} (created ${keep.created}) cancel=${cancel.length}`,
);
for (const r of cancel) {
console.log(` - ${r.id} (created ${r.created})`);
}
}
return;
}
let totalKept = 0;
let totalCancelled = 0;
let totalFailed = 0;
const failures: Array<{ id: string; error: string }> = [];
for (const group of groups) {
const sorted = [...group.records].sort((a, b) => a.created.localeCompare(b.created));
const keep = sorted[0];
const toCancel = sorted.slice(1);
totalKept += 1;
console.log(
`\ngroup ${group.kind}="${group.orderNumber}" (${group.records.length} records) keep=${keep.id} created=${keep.created}`,
);
for (const r of toCancel) {
try {
const result = await cancelOrder(r.id, { allowDelivered: true });
console.log(
` ✓ cancelled ${r.id} itemsCancelled=${result.itemsCancelled} itemsSkipped=${result.itemsSkipped.length}`,
);
totalCancelled += 1;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.log(` ✗ failed ${r.id}: ${msg}`);
failures.push({ id: r.id, error: msg });
totalFailed += 1;
}
}
}
console.log("\n=== Backfill summary ===");
console.log(` groups: ${groups.length}`);
console.log(` records kept: ${totalKept}`);
console.log(` cancelled: ${totalCancelled}`);
console.log(` failed: ${totalFailed}`);
if (failures.length > 0) {
console.log("\nFailures:");
for (const f of failures) {
console.log(` ${f.id}: ${f.error}`);
}
process.exitCode = 1;
}
}
main().catch((err) => {
console.error("Backfill failed:", err);
process.exit(1);
});