WFDASH-WEBAPP/scripts/migrations/2026-07-19-create-delivery-notes.ts
SJCoetz 7a68cafe35 chore: add logos, opencode config and build assets
This commit includes the temporary Next.js build cache files, public brand
logos, and project configuration added in this change.
2026-07-27 08:21:20 +02:00

566 lines
19 KiB
TypeScript

/**
* 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<PocketBase> {
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<void> {
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<Record<string, unknown>>;
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);
});
}