WFDASH-WEBAPP/scripts/migrations/2026-07-06-purchase-orders-send-fields.ts

193 lines
6.9 KiB
TypeScript
Raw Normal View History

/**
* 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);
});