Workflow-App/Workflow/scripts/pb_migration_shareholder_change.mjs

377 lines
12 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* pb_migration_shareholder_change.mjs
*
* ONE-OFF, IDEMPOTENT schema migration that adds the storage the
* "5 - Change in shareholders" section needs.
*
* ---------------------------------------------------------------------------
* WHY A MIGRATION INSTEAD OF RUNTIME ADMIN CALLS
* ---------------------------------------------------------------------------
* Editing a PocketBase collection schema requires superuser rights. Doing that
* from a request handler would ship admin credentials into the hot path and
* demand superuser auth on every deploy. This script applies the change ONCE,
* ahead of time; the running app then uses only ordinary record-level APIs
* (which lib/pb.ts already self-heals with PB_ADMIN_*).
*
* Re-running is safe: every step checks for the field/option first and skips it
* if already present. Run it as often as you like.
*
* ---------------------------------------------------------------------------
* WHAT IT CHANGES (all additive - no existing field, row or option is modified
* or removed)
* ---------------------------------------------------------------------------
* ysterbaard_resolutions + kind select
* director_change (default)
* shareholder_change
*
* ysterbaard_resolution_attendees + shares number (integer, optional)
* + director_role gains two options:
* transferor (seller of shares)
* transferee (buyer of shares)
*
* Existing resolutions default to kind='director_change', so section 4 keeps
* behaving exactly as before.
*
* ---------------------------------------------------------------------------
* USAGE
* ---------------------------------------------------------------------------
* node scripts/pb_migration_shareholder_change.mjs # apply
* node scripts/pb_migration_shareholder_change.mjs --check # report only
*
* Credentials come from .env.local (PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD).
*/
import { readFileSync } from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ENV_FILE = process.env.ENV_FILE || path.join(HERE, "..", ".env.local");
const RESOLUTIONS_COL = "pbc_266170881"; // ysterbaard_resolutions
const ATTENDEES_COL = "pbc_1608833669"; // ysterbaard_resolution_attendees
const NEW_KINDS = ["director_change", "shareholder_change"];
const NEW_SHARE_ROLES = ["transferor", "transferee"];
// ---------------------------------------------------------------------------
// env
// ---------------------------------------------------------------------------
function loadEnv() {
let raw = "";
try {
raw = readFileSync(ENV_FILE, "utf8");
} catch {
throw new Error(`Could not read env file: ${ENV_FILE}`);
}
// The "m" flag matters: without it "^" only anchors to the very start of the
// file, so every key except a leading one reads as missing.
const get = (key) => {
const m = raw.match(new RegExp(`^\\s*${key}\\s*=\\s*"?([^"\\r\\n]+)`, "m"));
return m ? m[1].trim() : "";
};
const cfg = {
url: (process.env.PB_URL || get("PB_URL")).replace(/\/+$/, ""),
email: process.env.PB_ADMIN_EMAIL || get("PB_ADMIN_EMAIL"),
password: process.env.PB_ADMIN_PASSWORD || get("PB_ADMIN_PASSWORD"),
};
const missing = Object.entries(cfg)
.filter(([, v]) => !v)
.map(([k]) => k);
if (missing.length) {
throw new Error(
`Missing config in ${ENV_FILE}: ${missing.join(", ")}. ` +
`Set them in the file or pass as environment variables.`
);
}
return cfg;
}
// ---------------------------------------------------------------------------
// pb
// ---------------------------------------------------------------------------
async function auth(cfg) {
const res = await fetch(`${cfg.url}/api/collections/_superusers/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: cfg.email, password: cfg.password }),
});
if (!res.ok) {
throw new Error(`Superuser auth failed: HTTP ${res.status} ${await res.text()}`);
}
return (await res.json()).token;
}
async function pbGet(token, url) {
const res = await fetch(url, { headers: { Authorization: token }, cache: "no-store" });
if (!res.ok) {
throw new Error(`GET ${url} failed: HTTP ${res.status} ${await res.text()}`);
}
return res.json();
}
// This PocketBase instance serves collection updates from
// PATCH /api/collections/{id}. The two routes newer PocketBase builds expose
// (PUT /api/collections/{id} and the PUT /api/collections upsert) both return
// 404 here, so PATCH is the verb to use - verified against the live instance,
// not guessed.
async function pbPatch(token, url, body) {
const res = await fetch(url, {
method: "PATCH",
headers: { Authorization: token, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`PATCH ${url} failed: HTTP ${res.status} ${await res.text()}`);
}
return res.json();
}
// ---------------------------------------------------------------------------
// schema helpers
// ---------------------------------------------------------------------------
//
// GET /api/collections/{id} returns the whole schema, including internal keys
// on every field. PATCH expects that same shape back, so the safe approach is:
// read -> mutate one slice -> write the rest verbatim.
// PocketBase stores select options as a plain array of strings on this
// instance (["existing","resigned",...]), NOT as {value,color} objects.
// optionValue tolerates both shapes so the script keeps working if a collection
// was ever edited through the admin UI.
function optionValue(v) {
return typeof v === "string" ? v : (v && v.value) || "";
}
function optionValues(field) {
return (field.values || []).map(optionValue).filter(Boolean);
}
function kindField() {
return {
name: "kind",
type: "select",
required: false,
presentable: false,
values: [...NEW_KINDS],
maxSelect: 1,
max: 0,
min: 0,
pattern: "",
autogeneratePattern: false,
};
}
function sharesField() {
return {
name: "shares",
type: "number",
required: false,
presentable: false,
onlyInt: true,
min: null,
max: null,
system: false,
};
}
/**
* Strip server-managed keys from a field read out of PB before writing it back.
* PocketBase derives `id` itself and refuses to accept `system: true` fields on
* create, so both are dropped. `hidden` is preserved explicitly because PB
* expects the key to be present.
*/
function stripServerKeys(field) {
const { id, system, hidden, ...rest } = field;
return { ...rest, hidden: Boolean(hidden), system: false };
}
function normaliseFields(fields) {
return fields.map(stripServerKeys);
}
// ---------------------------------------------------------------------------
// steps
// ---------------------------------------------------------------------------
/**
* Add `field` to a collection if `name` is not present.
* Returns "added" | "present" | "would-add".
*/
async function ensureField({ token, base, colId, field, checkOnly, label }) {
const url = `${base}/api/collections/${colId}`;
const current = await pbGet(token, url);
const fields = current.fields || [];
if (fields.some((f) => f.name === field.name)) {
console.log(` - ${label}: already present - skipped`);
return "present";
}
if (checkOnly) {
console.log(` - ${label}: would be added`);
return "would-add";
}
const updated = await pbPatch(token, url, {
...current,
fields: normaliseFields([...fields, field]),
});
if (!(updated.fields || []).some((f) => f.name === field.name)) {
throw new Error(`${label}: PATCH succeeded but field is missing`);
}
console.log(` + ${label}: added`);
return "added";
}
/**
* Append select options to an existing select field, keeping the current
* options and their order intact.
* Returns "added" | "present" | "would-add".
*/
async function ensureSelectOptions({
token,
base,
colId,
fieldName,
newValues,
checkOnly,
label,
}) {
const url = `${base}/api/collections/${colId}`;
const current = await pbGet(token, url);
const fields = current.fields || [];
const field = fields.find((f) => f.name === fieldName);
if (!field) {
throw new Error(`Select field "${fieldName}" does not exist on collection ${colId}`);
}
if (field.type !== "select") {
throw new Error(`Field "${fieldName}" is a "${field.type}", expected "select"`);
}
const have = new Set(optionValues(field));
const missing = newValues.filter((v) => !have.has(v));
if (missing.length === 0) {
console.log(` - ${label}: already present - skipped`);
return "present";
}
if (checkOnly) {
console.log(` - ${label}: would add ${missing.join(", ")}`);
return "would-add";
}
const updated = await pbPatch(token, url, {
...current,
fields: normaliseFields(
fields.map((f) =>
f.name === fieldName ? { ...f, values: [...optionValues(f), ...missing] } : f
)
),
});
const now = (updated.fields || []).find((f) => f.name === fieldName);
const nowHave = new Set(optionValues(now || {}));
const stillMissing = missing.filter((v) => !nowHave.has(v));
if (stillMissing.length) {
throw new Error(`${label}: PATCH succeeded but options missing`);
}
console.log(` + ${label}: added ${missing.join(", ")}`);
return "added";
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
async function main() {
const checkOnly = process.argv.includes("--check");
const cfg = loadEnv();
console.log("");
console.log("PB migration - change in shareholders");
console.log(` target : ${cfg.url}`);
console.log(` mode : ${checkOnly ? "CHECK ONLY (no writes)" : "APPLY"}`);
console.log("");
const token = await auth(cfg);
console.log("1. ysterbaard_resolutions");
await ensureField({
token,
base: cfg.url,
colId: RESOLUTIONS_COL,
field: kindField(),
checkOnly,
label: "kind select (director_change | shareholder_change)",
});
console.log("2. ysterbaard_resolution_attendees");
await ensureField({
token,
base: cfg.url,
colId: ATTENDEES_COL,
field: sharesField(),
checkOnly,
label: "shares number",
});
await ensureSelectOptions({
token,
base: cfg.url,
colId: ATTENDEES_COL,
fieldName: "director_role",
newValues: NEW_SHARE_ROLES,
checkOnly,
label: "director_role options (transferor | transferee)",
});
// ---- verify ----
console.log("");
console.log("Verification");
const res = await pbGet(token, `${cfg.url}/api/collections/${RESOLUTIONS_COL}`);
const att = await pbGet(token, `${cfg.url}/api/collections/${ATTENDEES_COL}`);
const kind = (res.fields || []).find((f) => f.name === "kind");
const shares = (att.fields || []).find((f) => f.name === "shares");
const role = (att.fields || []).find((f) => f.name === "director_role");
const roleValues = optionValues(role || {});
console.log(` resolutions.kind : ${kind ? optionValues(kind).join(", ") : "MISSING"}`);
console.log(
` attendees.shares : ${
shares ? `number${shares.onlyInt ? " (integer)" : ""}` : "MISSING"
}`
);
console.log(` attendees.director_role : ${roleValues.join(", ")}`);
const problems = [];
if (!kind) problems.push("resolutions.kind missing");
if (!shares) problems.push("attendees.shares missing");
for (const v of NEW_SHARE_ROLES) {
if (!roleValues.includes(v)) problems.push(`director_role.${v} missing`);
}
// Guard the option set that section 4 depends on.
for (const v of ["existing", "resigned", "appointed", "external"]) {
if (!roleValues.includes(v)) problems.push(`director_role.${v} LOST`);
}
console.log("");
if (problems.length) {
console.error(`FAILED: ${problems.join("; ")}`);
process.exitCode = 1;
return;
}
console.log(
checkOnly
? "Check complete. Re-run without --check to apply."
: "Migration complete. The app now needs only record-level API access."
);
console.log("");
}
main().catch((err) => {
console.error("");
console.error("Migration failed:", err.message);
console.error("");
process.exitCode = 1;
});