/** * Migration — 2026-07-19-create-delivery-notes.ts * * Creates the delivery-notes schema that the Invoicing flow needs to * back the "delivery note → quote → invoice" lifecycle. * * ## Background — why this exists * * Two flows collapse into one "delivery note" concept: * * Path A (supplier → shop → customer) * 1. Customer's items get ordered from a supplier, no customer PO * yet. Items arrive in the shop. * 2. Office creates a delivery note — the items are held for that * customer. The `order_items` rows already exist (from the * supplier PO); the delivery note is just a header that groups * them via `delivery_note_number` on each `order_items` row. * 3. Office creates a Quote with status=`delivery_note` (the * "delivery-note pricing" status). Operator fills in prices. * 4. Quote is sent to the customer with the delivery note. * 5. Customer sends back their PO → stamped on the delivery note * (`customer_po` field), status flips to `po_received`. * 6. Office creates the invoice from the quote + delivery note. * 7. Status flips to `invoiced` → `completed`. * * Path B (counter pickup) * 1. Customer comes to the counter, takes items from stock. * 2. Office creates a delivery note on the spot. New `order_items` * rows are created on the fly and stamped with the * `delivery_note_number` — same end state as Path A. * 3. The Quote + PO + Invoice flow is identical. * * ## Why one collection, no items collection * * Items live on `order_items` (which already has a `delivery_note_number` * field — see schema). A delivery note is a header; its lines are * `order_items` rows where `delivery_note_number = X`. No second * `delivery_note_items` collection — the string join is enough and * avoids a sync problem (each item would have to exist in two * collections at once). * * ## What this script does * * 1. Creates the `delivery_notes` collection. * - `delivery_note_number` — text, unique-indexed, the human-readable * identifier. Format `DN{YY}{MM}{DD}{NN}` (server-generated, daily * sequence), generated by the delivery-note API route. * - `customer`, `company` — relations to the customer / company. * Both optional: an ad-hoc delivery note (Path B counter pickup * with a brand-new customer) may have neither until the operator * picks one. * - `order` — relation to `orders`, OPTIONAL. Set when all the * `order_items` rows belong to a single order (the common case * in Path A). Null when items span multiple orders, or when there * is no order at all (Path B). * - `status` — select: `draft`, `sent`, `po_received`, `invoiced`, * `completed`, `cancelled`. Lifecycle in the comment block above. * - `delivery_date` — date the items were (or will be) delivered. * Optional because ad-hoc delivery notes can be created in * advance and the actual delivery might happen later. * - `customer_po` — text. The customer's PO number, filled in when * the customer sends their PO. Drives the transition to * `po_received` (via the API route, not enforced here). * - `notes` — free text. * - `created_by` — relation to users. Captures who triggered the * delivery note (the operator who hit "mark as delivery note" in * Invoicing, or the operator who created an ad-hoc one). * - Standard `id`, `created`, `updated`. * * 2. Reconciles `quotes.status.values` to add `delivery_note`. The * value is the "this quote is pricing a delivery note" status; * quotes with this status live in a separate tab on the Quotes page * (or get filtered into the "delivery_note" view). * * 3. Adds `delivery_note` (relation → `delivery_notes`, optional) to * the `quotes` collection. Lets a quote navigate back to the * delivery note it prices. * * Idempotent — safe to re-run. Will skip collections/fields that * already exist with the expected shape. * * Run with: * npx tsx scripts/migrations/2026-07-19-create-delivery-notes.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"; const CUSTOMERS = "customers"; const COMPANIES = "companies"; const ORDERS = "orders"; const USERS = "users"; const QUOTES = "quotes"; const ORDER_ITEMS = "order_items"; const DELIVERY_NOTE_STATUS_VALUES = [ "draft", "sent", "po_received", "invoiced", "completed", "cancelled", ]; const TARGET_QUOTE_STATUS_VALUES = [ "new", "claimed", "processed", "emailed", "approval_pending", "approved", "rejected", "problem", "complete", "cancelled", "follow_up", "email_failed", "unmatched_bounce", "template", "sending", "mail_failed", "delivery_note", // ← new — this quote is pricing a delivery note ]; export async function run(): Promise { const pb = await authedPb(); // ── 0. Resolve relation targets ───────────────────────────────── const customersCol = await pb.collections.getOne(CUSTOMERS); const companiesCol = await pb.collections.getOne(COMPANIES); const ordersCol = await pb.collections.getOne(ORDERS); const usersCol = await pb.collections.getOne(USERS); // ── 1. Create `delivery_notes` if missing, or patch missing fields let deliveryNotesCol: { id: string }; const existing = await pb.collections.getList(1, 1, { filter: `name = "${DELIVERY_NOTES}"`, }); const targetFields = [ { name: "created", type: "autodate", required: true, onCreate: true, onUpdate: false, }, { name: "updated", type: "autodate", required: true, onCreate: true, onUpdate: true, }, { // Text fields: PB stores max/min/pattern as top-level // properties (NOT nested under `options`). The // `options` wrapper is for legacy / schema-export JSON, // not for create. Using `options` returns a generic // 400 "values: Cannot be blank" which is the same error // a mis-shaped select field produces. name: "delivery_note_number", type: "text", required: true, max: 50, min: 1, pattern: "", }, { name: "customer", type: "relation", required: false, collectionId: customersCol.id, cascadeDelete: false, maxSelect: 1, }, { name: "company", type: "relation", required: false, collectionId: companiesCol.id, cascadeDelete: false, maxSelect: 1, }, { name: "order", type: "relation", required: false, collectionId: ordersCol.id, cascadeDelete: false, maxSelect: 1, }, { // Select fields: PB stores maxSelect and values as // top-level properties (NOT nested under `options`). // Verified by querying existing `item_status` on // order_items + `status` on quotes. name: "status", type: "select", required: true, maxSelect: 1, values: DELIVERY_NOTE_STATUS_VALUES, }, { name: "delivery_date", type: "date", required: false, }, { name: "customer_po", type: "text", required: false, max: 100, }, { name: "notes", type: "text", required: false, max: 5000, }, { name: "created_by", type: "relation", required: false, collectionId: usersCol.id, cascadeDelete: false, maxSelect: 1, }, ]; if (existing.items.length === 0) { try { deliveryNotesCol = await pb.collections.create({ name: DELIVERY_NOTES, type: "base", fields: targetFields, indexes: [ "CREATE UNIQUE INDEX idx_delivery_notes_number ON delivery_notes (delivery_note_number)", "CREATE INDEX idx_delivery_notes_customer ON delivery_notes (customer)", "CREATE INDEX idx_delivery_notes_order ON delivery_notes (order)", "CREATE INDEX idx_delivery_notes_status ON delivery_notes (status)", ], listRule: "@request.auth.id != ''", viewRule: "@request.auth.id != ''", createRule: "@request.auth.id != ''", updateRule: "@request.auth.id != ''", deleteRule: "@request.auth.id != ''", }); console.log( `[migration:create-delivery-notes] ✓ created ${DELIVERY_NOTES} (${deliveryNotesCol.id})`, ); } catch (createErr: unknown) { // PB returns a generic "Failed to create collection." on any // field validation error and buries the per-field reason in // `data.fields` or `response.data`. Dump the whole thing so // future debugging doesn't have to re-discover this. const e = createErr as { response?: { data?: unknown }; originalError?: { data?: unknown }; data?: unknown; message?: string; }; console.error( "[migration:create-delivery-notes] PB rejected the collection create. Full error:", JSON.stringify( { message: e.message, responseData: e.response?.data ?? e.originalError?.data ?? e.data, }, null, 2, ), ); throw createErr; } } else { deliveryNotesCol = existing.items[0]; // Patch any missing fields (e.g. partial schema from a prior dev // run, or the old status enum values from the previous version of // this collection). const existingFields = (deliveryNotesCol as unknown as { fields: Array<{ name: string; values?: string[] }>; }).fields; const haveNames = new Set(existingFields.map((f) => f.name)); const missing = targetFields.filter((f) => !haveNames.has(f.name)); let next = existingFields as Array>; let statusReconciled = false; if (missing.length > 0) { next = [...next, ...missing]; } // Always reconcile the status select values — replaces any old // (`ready`, `delivered`, `cancelled`) enum with the new lifecycle. // PB stores select values as a top-level `values` array on the // field, not nested under `options` — see the gotcha note at the // top of the file. The `options` wrapper shape returns a generic // 400 on create AND silently no-ops on update, so we set the flat // shape explicitly. next = next.map((f) => { if (f.name === "status" && f.type === "select") { statusReconciled = true; return { ...f, values: DELIVERY_NOTE_STATUS_VALUES }; } return f; }); if (missing.length > 0 || statusReconciled) { await pb.collections.update(DELIVERY_NOTES, { fields: next }); console.log( `[migration:create-delivery-notes] ✓ patched ${DELIVERY_NOTES} (added ${missing.length} field(s)${statusReconciled ? ", reconciled status enum" : ""})`, ); } else { console.log( `[migration:create-delivery-notes] ${DELIVERY_NOTES} already exists (${deliveryNotesCol.id}) with all expected fields — skip`, ); } } // ── 2. Reconcile `quotes.status.values` to add `delivery_note` ─── const quotesCol = await pb.collections.getOne(QUOTES); const qFields = (quotesCol.fields ?? []) as Array<{ name?: string; values?: string[]; }>; let qSchemaChanged = false; for (const f of qFields) { if (f.name !== "status") continue; const current = new Set(f.values ?? []); const target = new Set(TARGET_QUOTE_STATUS_VALUES); const needsAdd = [...target].some((v) => !current.has(v)); if (needsAdd) { f.values = [...target]; qSchemaChanged = true; } break; } if (qSchemaChanged) { await pb.collections.update(QUOTES, { fields: qFields }); console.log( `[migration:create-delivery-notes] ✓ added delivery_note to quotes.status.values`, ); } else { console.log( "[migration:create-delivery-notes] quotes.status already includes delivery_note — skip", ); } // ── 2b. Reconcile `order_items.item_status.values` ──────────────── // The Invoicing flow PATCHes `item_status = "delivery_note"` on // every order_item when the operator clicks "Create delivery note". // If "delivery_note" isn't in the select enum yet, PB rejects the // PATCH with the generic 400 "Failed to update record." and the // route 500s. This step makes sure the value is in the enum. // // We don't replace the full enum (that would conflict with the // create-stock migration's reconcile). We just union the new // value into whatever's already there. const orderItemsCol = await pb.collections.getOne(ORDER_ITEMS); const oiFields = (orderItemsCol.fields ?? []) as Array<{ name?: string; values?: string[]; }>; let oiSchemaChanged = false; for (const f of oiFields) { if (f.name !== "item_status") continue; const current = new Set(f.values ?? []); if (!current.has("delivery_note")) { f.values = [...(f.values ?? []), "delivery_note"]; oiSchemaChanged = true; } break; } if (oiSchemaChanged) { await pb.collections.update(ORDER_ITEMS, { fields: oiFields }); console.log( "[migration:create-delivery-notes] ✓ added delivery_note to order_items.item_status.values", ); } else { console.log( "[migration:create-delivery-notes] order_items.item_status already includes delivery_note — skip", ); } // ── 3. Add `delivery_note` (relation → delivery_notes) to `quotes` const qHasDeliveryNoteField = (qFields as Array<{ name: string }>).some( (f) => f.name === "delivery_note", ); if (!qHasDeliveryNoteField) { const nextQ = [ ...(quotesCol.fields ?? []), { name: "delivery_note", type: "relation", required: false, collectionId: deliveryNotesCol.id, cascadeDelete: false, maxSelect: 1, }, ]; await pb.collections.update(QUOTES, { fields: nextQ }); console.log( "[migration:create-delivery-notes] ✓ added delivery_note relation to quotes", ); } else { console.log( "[migration:create-delivery-notes] quotes.delivery_note relation already present — skip", ); } // ── 4. Verify ─────────────────────────────────────────────────── const vDn = await pb.collections.getOne(DELIVERY_NOTES); const vQ = await pb.collections.getOne(QUOTES); const vOi = await pb.collections.getOne(ORDER_ITEMS); const dnFieldNames = (vDn.fields as Array<{ name: string }>).map( (f) => f.name, ); const dnStatusValues = ( (vDn.fields as Array<{ name: string; values?: string[] }>).find( (f) => f.name === "status", )?.values ?? [] ); const qFieldNames = (vQ.fields as Array<{ name: string }>).map( (f) => f.name, ); const qStatusValues = ( (vQ.fields as Array<{ name: string; values?: string[] }>).find( (f) => f.name === "status", )?.values ?? [] ); const oiItemStatusValues = ( (vOi.fields as Array<{ name: string; values?: string[] }>).find( (f) => f.name === "item_status", )?.values ?? [] ); const checks: Array<[string, boolean]> = [ ["delivery_notes collection exists", dnFieldNames.length > 0], [ "delivery_notes.delivery_note_number exists", dnFieldNames.includes("delivery_note_number"), ], ["delivery_notes.customer exists", dnFieldNames.includes("customer")], ["delivery_notes.company exists", dnFieldNames.includes("company")], ["delivery_notes.order exists", dnFieldNames.includes("order")], ["delivery_notes.status exists", dnFieldNames.includes("status")], [ "delivery_notes.status includes draft", dnStatusValues.includes("draft"), ], [ "delivery_notes.status includes sent", dnStatusValues.includes("sent"), ], [ "delivery_notes.status includes po_received", dnStatusValues.includes("po_received"), ], [ "delivery_notes.status includes invoiced", dnStatusValues.includes("invoiced"), ], [ "delivery_notes.status includes completed", dnStatusValues.includes("completed"), ], [ "delivery_notes.status includes cancelled", dnStatusValues.includes("cancelled"), ], [ "delivery_notes.delivery_date exists", dnFieldNames.includes("delivery_date"), ], [ "delivery_notes.customer_po exists", dnFieldNames.includes("customer_po"), ], [ "delivery_notes.notes exists", dnFieldNames.includes("notes"), ], [ "delivery_notes.created_by exists", dnFieldNames.includes("created_by"), ], [ "quotes.status includes delivery_note", qStatusValues.includes("delivery_note"), ], [ "quotes.delivery_note relation exists", qFieldNames.includes("delivery_note"), ], // The Invoicing flow PATCHes item_status = "delivery_note" on // every order_item — if this value isn't in the enum, the PATCH // 400s and the whole delivery-note creation 500s. This check // makes the "did we add it?" question explicit in the verify // output. [ "order_items.item_status includes delivery_note", oiItemStatusValues.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:create-delivery-notes] verification failed — see ✗ lines above", ); } console.log("[migration:create-delivery-notes] Migration complete."); } if (require.main === module) { run().catch((err) => { console.error("[migration:create-delivery-notes] fatal:", err); process.exit(1); }); }