193 lines
6.9 KiB
TypeScript
193 lines
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);
|
||
|
|
});
|