This commit includes the temporary Next.js build cache files, public brand logos, and project configuration added in this change.
358 lines
11 KiB
TypeScript
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);
|
|
});
|
|
}
|