/** * Migration — 2026-07-19-delivery-notes-v3.ts * * Two schema changes that complete the delivery-note → quote → * invoice loop for v3. * * ## What it does * * 1. `order_items.order` becomes OPTIONAL. * The ad-hoc "Create Delivery Note" flow (v3-B) creates * `order_items` for counter pickups — items the customer * took from the shelf with no supplier order backing them. * Today the field is `required: true`; making it optional * lets `order_items` exist without a parent `orders` record * while still linking back to a delivery note via * `delivery_note_number`. The TypeScript `OrderItem.order` * type also flips to `string | null` in the same change. * * 2. `quote_attachments.kind` enum gets a new value * `delivery_note`. * When the office creates a quote from a delivery note * (v2 service: `createQuoteFromDeliveryNote`), we now * also re-render the delivery note PDF and attach it to * the new quote as a `quote_attachments` row with * `kind = "delivery_note"`. The email-send modal already * shows attachments and lets the operator check which * ones to include — the new `delivery_note` value flows * through the same UI so the operator can include "the * signed delivery note" + "the quote PDF" in the * customer email in one click. * * Idempotent — safe to re-run. Existing rows are unaffected * (the order field goes from required to optional, but no * existing rows become invalid because they already have * order values). Existing quote_attachments keep their kind * value. * * Run with: * npx tsx scripts/migrations/2026-07-19-delivery-notes-v3.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 ORDER_ITEMS = "order_items"; const QUOTE_ATTACHMENTS = "quote_attachments"; const QUOTE_ATTACHMENT_KIND_VALUES = [ "pdf", "image", "doc", "other", // "delivery_note" is the new value — a re-rendered delivery // note PDF attached to a quote so the office can email it // alongside the quote PDF in one click. Distinct from "pdf" // (which is the quote's own rendered PDF) so the email modal // can label them clearly: "Quote PDF" vs "Signed delivery note". "delivery_note", ]; export async function run(): Promise { const pb = await authedPb(); // ── 1. Make `order_items.order` optional ────────────────────── const orderItemsCol = await pb.collections.getOne(ORDER_ITEMS); const oiFields = (orderItemsCol.fields ?? []) as Array<{ name: string; required?: boolean; }>; let oiChanged = false; for (const f of oiFields) { if (f.name !== "order") continue; if (f.required !== false) { f.required = false; oiChanged = true; } break; } if (oiChanged) { await pb.collections.update(ORDER_ITEMS, { fields: oiFields }); console.log( "[migration:delivery-notes-v3] ✓ made order_items.order optional", ); } else { console.log( "[migration:delivery-notes-v3] order_items.order is already optional — skip", ); } // ── 2. Reconcile `quote_attachments.kind.values` ───────────── const quoteAttsCol = await pb.collections.getOne(QUOTE_ATTACHMENTS); const qaFields = (quoteAttsCol.fields ?? []) as Array<{ name: string; values?: string[]; }>; let qaChanged = false; for (const f of qaFields) { if (f.name !== "kind") continue; const current = new Set(f.values ?? []); const target = new Set(QUOTE_ATTACHMENT_KIND_VALUES); const needsAdd = [...target].some((v) => !current.has(v)); const needsRemove = [...current].some((v) => !target.has(v)); if (needsAdd || needsRemove) { f.values = [...target]; qaChanged = true; } break; } if (qaChanged) { await pb.collections.update(QUOTE_ATTACHMENTS, { fields: qaFields }); console.log( "[migration:delivery-notes-v3] ✓ added delivery_note to quote_attachments.kind.values", ); } else { console.log( "[migration:delivery-notes-v3] quote_attachments.kind already includes delivery_note — skip", ); } // ── 3. Verify ───────────────────────────────────────────────── const vOi = await pb.collections.getOne(ORDER_ITEMS); const vQa = await pb.collections.getOne(QUOTE_ATTACHMENTS); const oiOrderField = (vOi.fields as Array<{ name: string; required?: boolean }>).find( (f) => f.name === "order", ); const qaKindField = (vQa.fields as Array<{ name: string; values?: string[] }>).find( (f) => f.name === "kind", ); const checks: Array<[string, boolean]> = [ [ "order_items.order is optional", oiOrderField?.required === false, ], [ "quote_attachments.kind includes delivery_note", (qaKindField?.values ?? []).includes("delivery_note"), ], ]; 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-v3] verification failed — see ✗ lines above", ); } console.log("[migration:delivery-notes-v3] Migration complete."); } if (require.main === module) { run().catch((err) => { console.error("[migration:delivery-notes-v3] fatal:", err); process.exit(1); }); }