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