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

212 lines
6.6 KiB
TypeScript

/**
* Migration — 2026-07-19-delivery-notes-signed-copy.ts
*
* Adds a `signed_copy` file field to the `delivery_notes` collection.
*
* ## What it does
*
* The delivery note loop ends with the customer receiving the
* DN + the quote as one email, signing the DN, and sending it
* back with their PO. The DN the office sends out is the
* re-rendered PDF (auto-generated from `order_items`). The DN
* the office receives back is the SIGNED copy — the customer
* has written on it / printed-signed-scanned.
*
* The signed copy has to live on `delivery_notes` (not
* `order_items`, where the unsigned PDF is stored) so that the
* outbound "Send to customer" flow can find it and attach IT
* (not the unsigned one) to the email.
*
* ## What it does NOT do
*
* It does NOT enable a signature pad. A signature pad (drawn
* on canvas) is a separate feature; the operator at the
* counter can either:
* - scan / photograph the signed paper DN and upload the
* file via the detail page's "Upload signed copy" button
* - get the customer to email back the signed PDF and the
* operator forwards / saves it
* Either way the file lands on `signed_copy` and the next
* `createQuoteFromDeliveryNote` (or the operator's manual
* "Send to customer") attaches it.
*
* Idempotent — safe to re-run. Field is optional so existing
* records without a signed copy still load.
*
* Run with:
* npx tsx scripts/migrations/2026-07-19-delivery-notes-signed-copy.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";
export async function run(): Promise<void> {
const pb = await authedPb();
const dnCol = await pb.collections.getOne(DELIVERY_NOTES);
const fields = (dnCol.fields ?? []) as Array<{
name: string;
type: string;
required?: boolean;
options?: Record<string, unknown>;
}>;
const hasSignedCopy = fields.some(
(f) => f.name === "signed_copy" && f.type === "file",
);
const wantedMaxSelect = 1;
const wantedMaxSize = 20 * 1024 * 1024;
const wantedMimeTypes = [
"application/pdf",
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/heic",
];
// Decide whether we need to mutate. Two paths:
// - Field doesn't exist yet → add it with the wanted options.
// - Field exists but has wrong options (e.g. created via a
// previous run with `options: {...}` which PB normalised
// to defaults) → patch the options in place.
let needsUpdate = false;
let nextFields = fields;
if (!hasSignedCopy) {
nextFields = [
...fields,
{
name: "signed_copy",
type: "file",
required: false,
maxSelect: wantedMaxSelect,
maxSize: wantedMaxSize,
mimeTypes: wantedMimeTypes,
},
];
needsUpdate = true;
} else {
nextFields = fields.map((f) => {
if (f.name !== "signed_copy") return f;
const o = (f.options ?? {}) as {
maxSelect?: number;
maxSize?: number;
mimeTypes?: string[] | null;
};
const wrongSelect =
(o.maxSelect ?? 0) !== wantedMaxSelect;
const wrongSize =
(o.maxSize ?? 0) !== wantedMaxSize;
const wrongMime =
JSON.stringify((o.mimeTypes ?? []).slice().sort()) !==
JSON.stringify(wantedMimeTypes.slice().sort());
if (wrongSelect || wrongSize || wrongMime) {
needsUpdate = true;
return { ...f, maxSelect: wantedMaxSelect, maxSize: wantedMaxSize, mimeTypes: wantedMimeTypes };
}
return f;
});
}
if (needsUpdate) {
await pb.collections.update(DELIVERY_NOTES, { fields: nextFields });
console.log(
hasSignedCopy
? "[migration:delivery-notes-signed-copy] ✓ patched signed_copy file field options (maxSelect, maxSize, mimeTypes)"
: "[migration:delivery-notes-signed-copy] ✓ added signed_copy file field to delivery_notes",
);
} else {
console.log(
"[migration:delivery-notes-signed-copy] signed_copy file field already present with correct options — skip",
);
}
// ── Verify ───────────────────────────────────────────────────
const v = await pb.collections.getOne(DELIVERY_NOTES);
const vFields = (v.fields ?? []) as Array<{
name: string;
type: string;
maxSelect?: number;
maxSize?: number;
}>;
const signedCopy = vFields.find((f) => f.name === "signed_copy");
const checks: Array<[string, boolean]> = [
[
"delivery_notes.signed_copy exists",
!!signedCopy && signedCopy.type === "file",
],
[
"delivery_notes.signed_copy is single-file",
signedCopy?.maxSelect === 1,
],
[
"delivery_notes.signed_copy has size limit",
typeof signedCopy?.maxSize === "number" &&
(signedCopy.maxSize ?? 0) > 0,
],
];
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-signed-copy] verification failed — see ✗ lines above",
);
}
console.log("[migration:delivery-notes-signed-copy] Migration complete.");
}
if (require.main === module) {
run().catch((err) => {
console.error("[migration:delivery-notes-signed-copy] fatal:", err);
process.exit(1);
});
}