WFDASH-WEBAPP/scripts/migrations/2026-08-09-backfill-quote-validity-days.ts
Dave 397df8e2b2 fix: correct quote expiry handling for PocketBase's 0 default
PocketBase returns 0 for unset number fields, which broke the original
null-coalescing check in effectiveValidityDays, leading to PDFs showing
"Valid until: today" for all new quotes created via email ingest.

Changes include:
- Update effectiveValidityDays to treat 0, negatives, null/undefined as falling back to 30-day default
- Add test cases for 0 and negative validity_days inputs
- Explicitly set validity_days to QUOTE_VALID_DAYS when creating new quotes
- Add one-time idempotent migration script to backfill legacy invalid validity values
- Refactor item intel popover patch logic to use PocketBase SDK for authenticated requests
- Add n8n workflow for AI-powered item insight matching
2026-08-11 08:32:17 +02:00

153 lines
4.8 KiB
TypeScript

/* eslint-disable */
/**
* One-off data repair: stamp `validity_days = 30` on every quote
* where the field is missing, null, or zero.
*
* ## Background
*
* PocketBase returns `0` (not `null`) for an un-set number field. The
* PDF formatter originally used `quote.validity_days ?? 30`, which
* does *not* treat 0 as nullish. Every quote created via the email
* ingest path (`createQuoteFromEmail`) before this fix shipped
* ended up with `validity_days = 0`, so the generated PDF said
* "Valid until: today".
*
* This script does a one-shot patch to bring every existing quote
* to the package default. Quotes where an operator had explicitly
* set a value (any positive number) are left untouched.
*
* ## Usage
*
* Dry run (default — safe to run anytime):
*
* npx tsx scripts/migrations/2026-08-09-backfill-quote-validity-days.ts
*
* Apply the repair:
*
* npx tsx scripts/migrations/2026-08-09-backfill-quote-validity-days.ts --apply
*
* ## Idempotency
*
* The filter `validity_days = null || validity_days <= 0` only matches
* quotes that need fixing. Re-running after a successful apply
* updates zero rows and exits cleanly.
*/
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
function loadEnvFile(name: string) {
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*=\s*(.+)$/i);
if (m && !process.env[m[1]]) {
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
}
}
} catch {
// missing file — fine
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
const QUOTE_VALID_DAYS = 30;
const APPLY = process.argv.includes("--apply");
const PAGE_SIZE = 500;
async function main() {
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(
"Missing NEXT_PUBLIC_POCKETBASE_URL / PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD in env",
);
}
const pb = new PocketBase(url);
await pb.admins.authWithPassword(email, password);
// Match every quote that's missing / unset / zero / negative on
// validity_days. The shape mirrors `effectiveValidityDays`'s
// nullish handling so we patch exactly the rows the PDF code
// would currently fall back on.
const filter = 'validity_days = null || validity_days <= 0';
const all = (await pb.collection("quotes").getFullList({
filter,
fields: "id,quote_number,validity_days,quote_date",
sort: "quote_number",
})) as Array<{
id: string;
quote_number: string;
validity_days: number | null;
quote_date: string;
}>;
if (all.length === 0) {
console.log("No quotes with missing/0 validity_days — nothing to repair.");
return;
}
console.log(`Found ${all.length} quote(s) with missing or 0 validity_days.`);
console.log("");
console.log("First 20 rows that would be patched:");
for (const q of all.slice(0, 20)) {
console.log(
` ${q.quote_number.padEnd(16)} was=${String(q.validity_days).padStart(4)} → ${QUOTE_VALID_DAYS} (date=${q.quote_date || "—"})`,
);
}
if (all.length > 20) {
console.log(` ... and ${all.length - 20} more`);
}
console.log("");
if (!APPLY) {
console.log("DRY RUN — pass --apply to perform the repair.");
console.log("");
console.log("Re-run with: npx tsx scripts/migrations/2026-08-09-backfill-quote-validity-days.ts --apply");
return;
}
// PB doesn't expose a batch-update collection endpoint via the SDK
// for arbitrary fields. Update each row one-by-one. With 200 rows
// worst case this takes ~30 s.
console.log("Patching...");
let patched = 0;
let failed = 0;
for (const q of all) {
try {
await pb.collection("quotes").update(q.id, { validity_days: QUOTE_VALID_DAYS });
patched++;
} catch (err) {
failed++;
console.error(` ✗ ${q.quote_number} (${q.id}) failed:`, err);
}
}
console.log(` ✓ Patched ${patched} quote(s).`);
if (failed > 0) {
console.error(` ✗ ${failed} quote(s) failed.`);
}
// Re-query to confirm zero rows remain in the "needs-fixing" set.
const remaining = await pb.collection("quotes").getFullList({
filter,
fields: "id",
});
console.log("");
console.log(`Repair complete. Quotes still in the "needs fixing" set: ${remaining.length}`);
if (remaining.length > 0) {
console.error(
"WARNING: some quotes still have validity_days = 0/null — investigate before re-running.",
);
process.exit(1);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});