116 lines
4 KiB
TypeScript
116 lines
4 KiB
TypeScript
|
|
/**
|
||
|
|
* 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);
|
||
|
|
});
|