WFDASH-WEBAPP/scripts/migrations/2026-07-06-purchase-orders-send-fields.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

193 lines
No EOL
6.9 KiB
TypeScript

/**
* Migration — extend `purchase_orders` for the send-email lifecycle + add
* `purchase_order` relation to `invoice_emails` for supplier reply tracking.
*
* Why:
* The Send PO button (operator → Next.js → n8n → SMTP) needs:
* - `email_payload` (json) — the outbound composition (subject, body,
* cc, bcc, sentAt, supplierReplyMessageId, replyPreview) in one
* nested object. Mirrors the existing `quote_items.specifications`
* and `pos.specifications` JSON-field convention. One read pulls
* everything, one write updates everything.
* - `status` values — populate the select with `draft`, `sending`,
* `sent`, `mail_failed`, `cancelled`. Currently the select is empty.
* - `email_message_id` (text) — RFC 5322 Message-ID of the outbound send,
* used as the `In-Reply-To` header on follow-up emails and to match
* inbound replies via the `invoice_emails` collection.
* - `last_reply_at` (date) — wall-clock of the most recent supplier
* reply (when the IMAP poller lands — v2).
* - `email_error` (text) — last SMTP failure reason for display in
* the mail_failed badge tooltip.
*
* NOTE: `invoice_emails` (the collection referenced in src/lib/types.ts
* for reply tracking) does NOT exist in live PB. Reply tracking is
* deferred to a follow-up pass — for v1 we only ship the outbound send
* lifecycle. When reply tracking is built, the `email_message_id` field
* here will be the `In-Reply-To` anchor.
*
* Idempotent — skips every field that already exists.
*
* Run with:
* npx tsx scripts/migrations/2026-07-06-purchase-orders-send-fields.ts
*/
/* 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 — rely on shell env */
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
const PO_STATUS_VALUES = [
"draft",
"sending",
"sent",
"mail_failed",
"cancelled",
];
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 (e.g. sourced from .env.local).",
);
}
const pb = new PocketBase(url);
await pb.collection("_superusers").authWithPassword(email, password);
console.log(`[po-send-fields] Authenticated against ${url}`);
// ─── purchase_orders ─────────────────────────────────────────────────
const poCol = await pb.collections.getOne("purchase_orders");
const poFields = (poCol.fields as Array<{
name: string;
type: string;
required?: boolean;
options?: Record<string, unknown>;
maxSelect?: number | null;
}>).slice();
const additions: Array<Record<string, unknown>> = [];
let statusFieldMutated = false;
if (!poFields.some((f) => f.name === "email_payload")) {
additions.push({
name: "email_payload",
type: "json",
required: false,
options: { maxSize: 20000 },
});
}
if (!poFields.some((f) => f.name === "email_message_id")) {
additions.push({
name: "email_message_id",
type: "text",
required: false,
options: { min: 0, max: 512, pattern: "" },
});
}
if (!poFields.some((f) => f.name === "last_reply_at")) {
additions.push({
name: "last_reply_at",
type: "date",
required: false,
options: {},
});
}
if (!poFields.some((f) => f.name === "email_error")) {
additions.push({
name: "email_error",
type: "text",
required: false,
options: { min: 0, max: 1024, pattern: "" },
});
}
// Populate `status` select values (if it's a select and values are empty/missing).
const statusField = poFields.find((f) => f.name === "status");
if (statusField && statusField.type === "select") {
// PB v0.22 stores `values` + `maxSelect` at the field top level,
// not nested under `options`. v0.23+ uses nested `options`. This PB
// is v0.22 — read the existing shape, mutate at the same level.
const have = new Set(
(statusField.values as string[] | undefined) ??
((statusField.options as { values?: string[] } | undefined)?.values ??
[])
);
const missing = PO_STATUS_VALUES.filter((v) => !have.has(v));
if (missing.length > 0) {
const next = Array.from(new Set([...have, ...missing]));
const idx = poFields.findIndex((f) => f.name === "status");
poFields[idx] = {
...statusField,
values: next,
maxSelect: 1,
};
statusFieldMutated = true;
console.log(
`[po-send-fields] status select — adding ${missing.length} value(s): ${missing.join(", ")}`,
);
}
}
if (additions.length > 0) {
poFields.push(...additions);
}
// Update the collection when EITHER new fields were added OR the status
// select values were populated. The previous version of this migration
// only persisted on `additions.length > 0`, which silently skipped the
// status-values mutation when no fields were added (and left the PO
// status field with only `draft/sent/received`, breaking the
// "sending" flip in the Send PO route).
if (additions.length === 0 && !statusFieldMutated) {
console.log(
"[po-send-fields] purchase_orders — no new fields or status values to add (skip).",
);
} else {
await pb.collections.update(poCol.id, { fields: poFields });
console.log(
`[po-send-fields] purchase_orders — added ${additions.length} field(s), status mutated=${statusFieldMutated}`,
);
}
// ─── Verify ──────────────────────────────────────────────────────────
const verifyPO = await pb.collections.getOne("purchase_orders");
const summary = (verifyPO.fields as Array<{ name: string; type: string }>)
.filter((f) =>
[
"email_payload",
"email_message_id",
"last_reply_at",
"email_error",
"status",
].includes(f.name),
)
.map((f) => `${f.name}:${f.type}`)
.join(", ");
console.log(`[po-send-fields] Post-update purchase_orders → ${summary}`);
console.log("[po-send-fields] Migration complete.");
}
main().catch((err) => {
console.error("[po-send-fields] Migration failed:", err);
process.exit(1);
});