This commit includes the temporary Next.js build cache files, public brand logos, and project configuration added in this change.
566 lines
19 KiB
TypeScript
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);
|
|
});
|
|
}
|