/** * Migration — 2026-07-19-delivery-notes-signed-copy.ts * * Adds a `signed_copy` file field to the `delivery_notes` collection. * * ## What it does * * The delivery note loop ends with the customer receiving the * DN + the quote as one email, signing the DN, and sending it * back with their PO. The DN the office sends out is the * re-rendered PDF (auto-generated from `order_items`). The DN * the office receives back is the SIGNED copy — the customer * has written on it / printed-signed-scanned. * * The signed copy has to live on `delivery_notes` (not * `order_items`, where the unsigned PDF is stored) so that the * outbound "Send to customer" flow can find it and attach IT * (not the unsigned one) to the email. * * ## What it does NOT do * * It does NOT enable a signature pad. A signature pad (drawn * on canvas) is a separate feature; the operator at the * counter can either: * - scan / photograph the signed paper DN and upload the * file via the detail page's "Upload signed copy" button * - get the customer to email back the signed PDF and the * operator forwards / saves it * Either way the file lands on `signed_copy` and the next * `createQuoteFromDeliveryNote` (or the operator's manual * "Send to customer") attaches it. * * Idempotent — safe to re-run. Field is optional so existing * records without a signed copy still load. * * Run with: * npx tsx scripts/migrations/2026-07-19-delivery-notes-signed-copy.ts */ 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 { /* fine — rely on shell env */ } } loadEnvFile(".env"); loadEnvFile(".env.local"); function getAdminPocketBase(): PocketBase { 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 (sourced from .env.local).", ); } const pb = new PocketBase(url); return pb; } async function authedPb(): Promise { const pb = getAdminPocketBase(); await pb .collection("_superusers") .authWithPassword( process.env.PB_ADMIN_EMAIL!, process.env.PB_ADMIN_PASSWORD!, ); return pb; } const DELIVERY_NOTES = "delivery_notes"; export async function run(): Promise { const pb = await authedPb(); const dnCol = await pb.collections.getOne(DELIVERY_NOTES); const fields = (dnCol.fields ?? []) as Array<{ name: string; type: string; required?: boolean; options?: Record; }>; const hasSignedCopy = fields.some( (f) => f.name === "signed_copy" && f.type === "file", ); const wantedMaxSelect = 1; const wantedMaxSize = 20 * 1024 * 1024; const wantedMimeTypes = [ "application/pdf", "image/png", "image/jpeg", "image/jpg", "image/webp", "image/heic", ]; // Decide whether we need to mutate. Two paths: // - Field doesn't exist yet → add it with the wanted options. // - Field exists but has wrong options (e.g. created via a // previous run with `options: {...}` which PB normalised // to defaults) → patch the options in place. let needsUpdate = false; let nextFields = fields; if (!hasSignedCopy) { nextFields = [ ...fields, { name: "signed_copy", type: "file", required: false, maxSelect: wantedMaxSelect, maxSize: wantedMaxSize, mimeTypes: wantedMimeTypes, }, ]; needsUpdate = true; } else { nextFields = fields.map((f) => { if (f.name !== "signed_copy") return f; const o = (f.options ?? {}) as { maxSelect?: number; maxSize?: number; mimeTypes?: string[] | null; }; const wrongSelect = (o.maxSelect ?? 0) !== wantedMaxSelect; const wrongSize = (o.maxSize ?? 0) !== wantedMaxSize; const wrongMime = JSON.stringify((o.mimeTypes ?? []).slice().sort()) !== JSON.stringify(wantedMimeTypes.slice().sort()); if (wrongSelect || wrongSize || wrongMime) { needsUpdate = true; return { ...f, maxSelect: wantedMaxSelect, maxSize: wantedMaxSize, mimeTypes: wantedMimeTypes }; } return f; }); } if (needsUpdate) { await pb.collections.update(DELIVERY_NOTES, { fields: nextFields }); console.log( hasSignedCopy ? "[migration:delivery-notes-signed-copy] ✓ patched signed_copy file field options (maxSelect, maxSize, mimeTypes)" : "[migration:delivery-notes-signed-copy] ✓ added signed_copy file field to delivery_notes", ); } else { console.log( "[migration:delivery-notes-signed-copy] signed_copy file field already present with correct options — skip", ); } // ── Verify ─────────────────────────────────────────────────── const v = await pb.collections.getOne(DELIVERY_NOTES); const vFields = (v.fields ?? []) as Array<{ name: string; type: string; maxSelect?: number; maxSize?: number; }>; const signedCopy = vFields.find((f) => f.name === "signed_copy"); const checks: Array<[string, boolean]> = [ [ "delivery_notes.signed_copy exists", !!signedCopy && signedCopy.type === "file", ], [ "delivery_notes.signed_copy is single-file", signedCopy?.maxSelect === 1, ], [ "delivery_notes.signed_copy has size limit", typeof signedCopy?.maxSize === "number" && (signedCopy.maxSize ?? 0) > 0, ], ]; let allOk = true; for (const [label, ok] of checks) { console.log(` ${ok ? "✓" : "✗"} ${label}`); if (!ok) allOk = false; } if (!allOk) { throw new Error( "[migration:delivery-notes-signed-copy] verification failed — see ✗ lines above", ); } console.log("[migration:delivery-notes-signed-copy] Migration complete."); } if (require.main === module) { run().catch((err) => { console.error("[migration:delivery-notes-signed-copy] fatal:", err); process.exit(1); }); }