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