Adds the full Next.js app (app/, components/, lib/, scripts/, assets/, docs/, Dockerfile, compose, configs) to version control. Security: - CIPC BizPortal customer-code logins (Login 1 + Login 3) now read CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE / CIPC_LOGIN3_PASSWORD from .env.local instead of source. - BizPortal personal login in apply-bizportal route reads BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD. - Added .env.example as the committed template. .gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright storage_state.json (live CIPC cookies), scratch dirs, client PDFs and debug screenshots.
377 lines
No EOL
12 KiB
JavaScript
377 lines
No EOL
12 KiB
JavaScript
#!/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;
|
|
}); |