WFDASH-WEBAPP/scripts/migrations/2026-07-06-purchase-orders-documents-field.ts
SJCoetz 7f924ccc45 refactor: switch hardcoded UI colors to semantic theme tokens
Overhaul the app's color system by replacing all hardcoded Tailwind color classes with semantic theme variables to ensure consistent theming across all components, pages, and tests.

Additional changes:
- Add assigned date support for order item status updates: add `assignedDate` field to the order item status PATCH API, implement server-side normalization/validation, and add corresponding tests
- Refactor purchase order PDF workflows to use the consolidated `purchase_orders.documents` field instead of the legacy separate `purchase_order_pdfs` collection
- Fix quote number parsing and formatting to support both dashed (Q-YYYY-MM-NNN) and dashless (QYYYYMMNNN) formats
- Rename `driver_pool` item status to `collection_pool` across the codebase
- Add null handling for order item quantities and pricing to display "—" for missing values
- Improve filter tab styling: make mobile tabs scrollable, prevent text wrapping, hide scrollbar
- Add new utility scripts, API routes, migrations, and polish small UI/UX fixes including tsconfig updates and theme hook improvements
2026-07-07 17:22:03 +02:00

296 lines
No EOL
10 KiB
TypeScript

/**
* Migration — consolidate PO documents onto `purchase_orders`.
*
* Adds two fields to the `purchase_orders` collection:
* - `documents` (file, multi) — the latest PO PDF + history.
* - `documents_history` (json) — read-only audit log of
* every regen (version,
* generatedAt, generatedBy,
* reason).
*
* Why consolidate:
* Today the PO PDF lives in a separate `purchase_order_pdfs` collection
* (one row per regen, status="current" flag for the latest). Every
* Send PO flow does THREE PB round-trips to fetch the attachment:
* 1. fetch PO
* 2. fetch latest row from `purchase_order_pdfs` for that PO
* 3. fetch the file bytes via the proxy
* Consolidating onto `purchase_orders.documents` (file, multi) shrinks
* this to TWO round-trips — and lets the webhook payload include the
* filename directly without a separate query. `documents_history`
* keeps the per-regen audit trail without a separate collection.
*
* Backfill:
* For every PO that already has `purchase_order_pdfs` rows, copy:
* - `documents` ← the file from the row with status="current"
* (or the highest-version row if no "current")
* - `documents_history` ← array of `{version, file, generatedAt,
* generatedBy, reason}` for ALL rows
* (ordered by version ASC), so future regens
* preserve the audit trail.
*
* Idempotent — skips the field-add when fields already exist, and the
* backfill is a no-op when documents is already populated.
*
* Run with:
* npx tsx scripts/migrations/2026-07-06-purchase-orders-documents-field.ts
*
* Archive step (separate, run after this is verified):
* After the dashboard reads from `documents` everywhere, mark the
* `purchase_order_pdfs` collection `is_active=false` in PB admin so
* new writes are blocked. The collection can stay around for audit
* queries (the file proxy still whitelists it).
*/
/* eslint-disable */
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 */
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
const PO_COLLECTION = "purchase_orders";
const PDF_COLLECTION = "purchase_order_pdfs";
interface PbField {
name: string;
type: string;
required?: boolean;
options?: Record<string, unknown>;
maxSelect?: number | null;
}
interface PdfRow {
id: string;
purchase_order: string;
file: string;
version: number;
status: string;
generated_at: string;
generated_by: string;
notes: string;
}
interface PoRow {
id: string;
documents?: string[];
documents_history?: Array<{
version: number;
file: string;
generatedAt: string;
generatedBy: string;
reason: string;
status: string;
}>;
}
async function main() {
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.",
);
}
const pb = new PocketBase(url);
await pb.collection("_superusers").authWithPassword(email, password);
console.log(`[po-documents] Authenticated against ${url}`);
// ─── 1. Add fields to purchase_orders ─────────────────────────────
const poCol = await pb.collections.getOne(PO_COLLECTION);
const poFields = (poCol.fields as PbField[]).slice();
const additions: PbField[] = [];
if (!poFields.some((f) => f.name === "documents")) {
additions.push({
name: "documents",
type: "file",
required: false,
options: {
maxSelect: 20,
maxSize: 10485760, // 10 MB per file — most PO PDFs are <500KB
mimeTypes: ["application/pdf", "image/png", "image/jpeg"],
// PB v0.22 needs these for file fields even though it's
// conceptually `[]` (no thumbs) for headless server usage.
thumbs: [],
},
});
console.log("[po-documents] documents field: ADD");
} else {
console.log("[po-documents] documents field: skip (already exists)");
}
if (!poFields.some((f) => f.name === "documents_history")) {
additions.push({
name: "documents_history",
type: "json",
required: false,
options: { maxSize: 50000 },
});
console.log("[po-documents] documents_history field: ADD");
} else {
console.log("[po-documents] documents_history field: skip (already exists)");
}
if (additions.length > 0) {
poFields.push(...additions);
await pb.collections.update(poCol.id, { fields: poFields });
console.log(
`[po-documents] purchase_orders — added ${additions.length} field(s)`,
);
}
// ─── 2. Backfill documents + documents_history from purchase_order_pdfs ─
// PB file fields don't accept plain filename strings — they need an
// actual file upload via FormData. So for each PO with PDF rows we
// need to:
// (a) download each PDF's bytes from PB (admin client URL)
// (b) re-upload to purchase_orders.documents via FormData
// (c) populate documents_history with the metadata (no file, just JSON)
const pdfList = await pb.collection(PDF_COLLECTION).getFullList<PdfRow>({
sort: "purchase_order,version",
});
console.log(`[po-documents] Found ${pdfList.length} purchase_order_pdfs rows`);
// Group by purchase_order
const byPo = new Map<string, PdfRow[]>();
for (const row of pdfList) {
const arr = byPo.get(row.purchase_order) ?? [];
arr.push(row);
byPo.set(row.purchase_order, arr);
}
console.log(`[po-documents] Grouped into ${byPo.size} PO(s)`);
let migrated = 0;
let skipped = 0;
let failed = 0;
for (const [poId, rows] of byPo) {
try {
// Skip if PO already has documents (re-run safety).
const current = await pb
.collection(PO_COLLECTION)
.getOne<PoRow>(poId, { fields: "id,documents,documents_history" });
if (
Array.isArray(current.documents) &&
current.documents.length > 0
) {
skipped++;
continue;
}
// Latest = status="current" first, else highest version.
const sorted = rows.slice().sort((a, b) => {
if (a.status === "current" && b.status !== "current") return -1;
if (b.status === "current" && a.status !== "current") return 1;
return b.version - a.version;
});
const latest = sorted[0];
// Build the history array (asc by version — newest at the END).
const history = rows
.slice()
.sort((a, b) => a.version - b.version)
.map((r) => ({
version: r.version,
file: r.file,
generatedAt: r.generated_at || "",
generatedBy: r.generated_by || "",
reason: r.notes || "",
status: r.status,
}));
// Download the latest PDF bytes via admin PB file URL.
const downloadUrl = pb.files.getURL(
{ collectionId: PDF_COLLECTION, id: latest.id },
latest.file,
);
const fileRes = await fetch(downloadUrl, {
headers: { Authorization: pb.authStore.token ?? "" },
});
if (!fileRes.ok) {
throw new Error(
`PDF download failed: HTTP ${fileRes.status} on ${downloadUrl}`,
);
}
const fileBlob = await fileRes.blob();
// PB expects a File object, not a Blob — wrap with the original name.
const file = new File([fileBlob], latest.file, {
type: fileRes.headers.get("content-type") || "application/pdf",
});
// Re-upload to purchase_orders.documents (file, multi — array of File).
// We only migrate the LATEST file (history captures the rest as
// metadata; if the operator ever wants to download an older
// version, they still can via the legacy `purchase_order_pdfs`
// collection until we archive it).
const formData = new FormData();
formData.append("documents", file);
const updated = await pb.collection(PO_COLLECTION).update<PurchaseOrder>(
poId,
formData,
);
// PB appends a random suffix to file uploads to prevent
// collisions (e.g. `foo.pdf` → `foo_uc56p4ttjl.pdf`). Read back
// the actual stored filename from the response and update the
// history entry to match — otherwise the download URL won't
// resolve. Same applies to every entry's `file` field.
const actualStoredFiles: string[] = Array.isArray(updated.documents)
? updated.documents
: updated.documents
? [updated.documents]
: [];
const lastStoredFile = actualStoredFiles[actualStoredFiles.length - 1];
const syncedHistory = history.map((h, idx) =>
// The LAST history entry corresponds to the LAST stored file.
// For pre-migration data we can only sync that one reliably —
// older versions stay archived in the legacy collection.
idx === history.length - 1 && lastStoredFile
? { ...h, file: lastStoredFile }
: h,
);
// Then write documents_history separately (it's JSON, not a file).
await pb.collection(PO_COLLECTION).update(poId, {
documents_history: syncedHistory,
});
migrated++;
console.log(
`[po-documents] PO ${poId}: ${rows.length} PDF row(s) → documents=[${latest.file}] (${fileBlob.size}B), history[${history.length}]`,
);
} catch (err) {
failed++;
console.warn(`[po-documents] PO ${poId}: failed to migrate:`, err);
}
}
console.log(
`[po-documents] Backfill complete — migrated=${migrated}, skipped=${skipped}, failed=${failed}`,
);
console.log("[po-documents] Migration complete.");
}
main().catch((err) => {
console.error("[po-documents] Migration failed:", err);
process.exit(1);
});