WFDASH-WEBAPP/scripts/migrations/2026-07-19-delivery-notes-v3.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

202 lines
6.6 KiB
TypeScript

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