WFDASH-WEBAPP/scripts/migrations/2026-07-24-partial-approval.ts
SJCoetz 7a68cafe35 chore: add logos, opencode config and build assets
This commit includes the temporary Next.js build cache files, public brand
logos, and project configuration added in this change.
2026-07-27 08:21:20 +02:00

358 lines
11 KiB
TypeScript

/**
* Migration — 2026-07-24-partial-approval.ts
*
* Adds the schema needed by the partial-approval flow
* (sprint-priced items can be approved + sent in batches;
* unpriced items get cloned to a continuation quote).
*
* ## What this script does
*
* 1. Reconciles `quotes.status` to add the two new enum values:
* `partial_approval_pending` and `partial_approved`.
* The existing values are kept; we only APPEND the new ones.
* This is the path the operator's "Submit for Approval" /
* "Approve & Send" actions take when only some items on the
* quote are priced.
*
* 2. Adds `parent_quote` (relation → `quotes`, single, optional,
* cascadeDelete: false) to the `quotes` collection. When a
* continuation quote is created from a partial-approved
* parent, the continuation's `parent_quote` points back to
* the original. Lets the UI render "Continuation of Q-..."
* banners and a "View parent" back-link.
*
* 3. Adds `approved_in_batch` (bool, default false) to
* `quote_items`. Set to true by /api/v1/quotes/[id]/partial-approve
* on the priced items the rep just approved. Gates the
* re-submission flow — a second partial approval only
* considers items where `approved_in_batch` is still false.
*
* 4. Adds `moved_to_quote` (relation → `quotes`, single,
* optional, cascadeDelete: false) to `quote_items`. Set by
* /api/v1/quotes/[id]/split to the continuation quote an
* item was cloned to. The PDF route and approval UI filter
* out items where `moved_to_quote != null` so a parent's
* PDF/approval state never re-includes items that now
* belong to a separate quote.
*
* ## Why a migration script (and not "user does it in admin")
*
* The user's AGENTS.md rule about "PocketBase select fields are
* not self-service" is about RUNTIME — if the app code PATCHes a
* status to a value that doesn't exist, the API 500s. The
* migration script is exactly the way you add the enum value
* programmatically. After this runs, the admin UI shows the new
* values too (and the user can re-order them if they want).
*
* ## Idempotency
*
* Safe to re-run. Each step reads the current collection state,
* computes a diff, and only PATCHes if something is missing.
*
* ## Run
*
* npx tsx scripts/migrations/2026-07-24-partial-approval.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 QUOTES = "quotes";
const QUOTE_ITEMS = "quote_items";
/**
* Target `quotes.status` enum. Mirrors the union in
* `src/lib/types.ts` → `QuoteStatus`. The migration script is
* the source of truth for what the live PB actually accepts; the
* TS type is checked against this in code review.
*/
const TARGET_QUOTE_STATUS_VALUES = [
"new",
"claimed",
"processed",
"emailed",
"approval_pending",
"approved",
"rejected",
"problem",
"complete",
"cancelled",
"follow_up",
"email_failed",
"unmatched_bounce",
"template",
"sending",
"mail_failed",
"delivery_note",
// Added 2026-07-24 — partial-approval flow.
"partial_approval_pending",
"partial_approved",
];
type PbField = {
name: string;
type: string;
required?: boolean;
values?: string[];
collectionId?: string;
maxSelect?: number;
cascadeDelete?: boolean;
options?: Record<string, unknown>;
[k: string]: unknown;
};
export async function run(): Promise<void> {
const pb = await authedPb();
const quotesCol = await pb.collections.getOne(QUOTES);
const quoteItemsCol = await pb.collections.getOne(QUOTE_ITEMS);
const quotesFields = (quotesCol.fields ?? []) as PbField[];
const itemsFields = (quoteItemsCol.fields ?? []) as PbField[];
// ── 1. Reconcile quotes.status enum ──────────────────────────
// APPEND the two new values if missing. We never delete or
// reorder existing values — backwards compat with already-stored
// quote rows. PB stores select values as a TOP-LEVEL `values`
// array (not nested under `options`) — see the gotcha note in
// the project AGENTS.md and the 2026-07-19 delivery-notes
// migration that ran into the same trap.
let nextQuotesFields = quotesFields;
let statusNeedsUpdate = false;
const statusField = nextQuotesFields.find(
(f) => f.name === "status" && f.type === "select",
);
const existingValues = new Set(statusField?.values ?? []);
const missingValues = TARGET_QUOTE_STATUS_VALUES.filter(
(v) => !existingValues.has(v),
);
if (missingValues.length > 0) {
const merged = [
...(statusField?.values ?? []),
...missingValues,
];
nextQuotesFields = nextQuotesFields.map((f) =>
f.name === "status" && f.type === "select"
? { ...f, values: merged }
: f,
);
statusNeedsUpdate = true;
}
// ── 2. Add parent_quote relation to quotes ───────────────────
const hasParentQuote = nextQuotesFields.some(
(f) => f.name === "parent_quote" && f.type === "relation",
);
if (!hasParentQuote) {
nextQuotesFields = [
...nextQuotesFields,
{
name: "parent_quote",
type: "relation",
required: false,
collectionId: quotesCol.id, // self-reference — set after getOne above
cascadeDelete: false,
maxSelect: 1,
},
];
}
// ── 3. Add approved_in_batch bool to quote_items ─────────────
const hasApprovedInBatch = itemsFields.some(
(f) => f.name === "approved_in_batch" && f.type === "bool",
);
// ── 4. Add moved_to_quote relation to quote_items ────────────
const hasMovedToQuote = itemsFields.some(
(f) => f.name === "moved_to_quote" && f.type === "relation",
);
const itemsNeedsUpdate =
!hasApprovedInBatch || !hasMovedToQuote;
let nextItemsFields = itemsFields;
if (itemsNeedsUpdate) {
if (!hasApprovedInBatch) {
nextItemsFields = [
...nextItemsFields,
{ name: "approved_in_batch", type: "bool", required: false },
];
}
if (!hasMovedToQuote) {
nextItemsFields = [
...nextItemsFields,
{
name: "moved_to_quote",
type: "relation",
required: false,
collectionId: quotesCol.id, // target = quotes
cascadeDelete: false,
maxSelect: 1,
},
];
}
}
// ── Apply the updates ────────────────────────────────────────
if (statusNeedsUpdate || !hasParentQuote) {
await pb.collections.update(QUOTES, { fields: nextQuotesFields });
const parts: string[] = [];
if (statusNeedsUpdate) {
parts.push(`status enum +${missingValues.length} value(s)`);
}
if (!hasParentQuote) {
parts.push("added parent_quote relation");
}
console.log(
`[migration:partial-approval] ✓ patched ${QUOTES}: ${parts.join(", ")}`,
);
} else {
console.log(
`[migration:partial-approval] ${QUOTES} already up to date — skip`,
);
}
if (itemsNeedsUpdate) {
await pb.collections.update(QUOTE_ITEMS, { fields: nextItemsFields });
const parts: string[] = [];
if (!hasApprovedInBatch) parts.push("added approved_in_batch bool");
if (!hasMovedToQuote) parts.push("added moved_to_quote relation");
console.log(
`[migration:partial-approval] ✓ patched ${QUOTE_ITEMS}: ${parts.join(", ")}`,
);
} else {
console.log(
`[migration:partial-approval] ${QUOTE_ITEMS} already up to date — skip`,
);
}
// ── Verify ───────────────────────────────────────────────────
const vQuotes = await pb.collections.getOne(QUOTES);
const vItems = await pb.collections.getOne(QUOTE_ITEMS);
const vQuotesFields = (vQuotes.fields ?? []) as PbField[];
const vItemsFields = (vItems.fields ?? []) as PbField[];
const checks: Array<[string, boolean]> = [
[
"quotes.status contains partial_approval_pending",
(vQuotesFields.find((f) => f.name === "status")?.values ?? []).includes(
"partial_approval_pending",
),
],
[
"quotes.status contains partial_approved",
(vQuotesFields.find((f) => f.name === "status")?.values ?? []).includes(
"partial_approved",
),
],
[
"quotes.parent_quote is a relation to quotes",
(() => {
const f = vQuotesFields.find((f) => f.name === "parent_quote");
return (
!!f &&
f.type === "relation" &&
f.collectionId === vQuotes.id &&
(f.maxSelect ?? 0) === 1
);
})(),
],
[
"quote_items.approved_in_batch is a bool",
(() => {
const f = vItemsFields.find((f) => f.name === "approved_in_batch");
return !!f && f.type === "bool";
})(),
],
[
"quote_items.moved_to_quote is a relation to quotes",
(() => {
const f = vItemsFields.find((f) => f.name === "moved_to_quote");
return (
!!f &&
f.type === "relation" &&
f.collectionId === vQuotes.id &&
(f.maxSelect ?? 0) === 1
);
})(),
],
];
let allOk = true;
for (const [label, ok] of checks) {
console.log(` ${ok ? "✓" : "✗"} ${label}`);
if (!ok) allOk = false;
}
if (!allOk) {
throw new Error(
"[migration:partial-approval] verification failed — see ✗ lines above",
);
}
console.log("[migration:partial-approval] Migration complete.");
}
if (require.main === module) {
run().catch((err: unknown) => {
console.error("[migration:partial-approval] fatal:", err);
if (err && typeof err === "object" && "response" in err) {
const e = err as {
response?: { data?: unknown };
originalError?: { data?: unknown };
data?: unknown;
message?: string;
};
console.error(
"Full error:",
JSON.stringify(
{
message: e.message,
responseData: e.response?.data ?? e.originalError?.data ?? e.data,
},
null,
2,
),
);
}
process.exit(1);
});
}