Workflow-App/Workflow/scripts/pb_migration_shareholder_certificates.mjs
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
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.
2026-10-18 15:21:45 +02:00

239 lines
No EOL
8.5 KiB
JavaScript

#!/usr/bin/env node
/**
* pb_migration_shareholder_certificates.mjs
*
* ONE-OFF, IDEMPOTENT schema migration that gives section 5 a place to file the
* signed share certificates.
*
* ---------------------------------------------------------------------------
* WHY A SECOND FILE SLOT
* ---------------------------------------------------------------------------
* A change in shareholding produces TWO pieces of paperwork, not one:
*
* 1. the special resolution, signed by the shareholders → signed_letter
* 2. the NEW share certificates, also signed → signed_certificates
*
* The certificates are produced outside the app and uploaded once signed. A
* single slot would mean the second upload silently destroyed the first — which
* is exactly the failure the destination picker in section 4 exists to prevent.
*
* ---------------------------------------------------------------------------
* WHY A MIGRATION INSTEAD OF RUNTIME ADMIN CALLS
* ---------------------------------------------------------------------------
* Editing a collection schema needs superuser rights. Doing that from a request
* handler would put admin credentials in the hot path. This applies the change
* ONCE, ahead of time; the running app then uses only record-level APIs.
*
* Re-running is safe. Run it as often as you like.
*
* ---------------------------------------------------------------------------
* WHAT IT CHANGES (additive only — nothing existing is modified or removed)
* ---------------------------------------------------------------------------
* ysterbaard_resolutions + signed_certificates file (PDF, max 15 MB)
*
* ---------------------------------------------------------------------------
* USAGE
* ---------------------------------------------------------------------------
* node scripts/pb_migration_shareholder_certificates.mjs # apply
* node scripts/pb_migration_shareholder_certificates.mjs --check # report
*
* 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 MAX_BYTES = 15 * 1024 * 1024;
// ---------------------------------------------------------------------------
// env
// ---------------------------------------------------------------------------
function loadEnv() {
const cfg = {};
try {
for (const line of readFileSync(ENV_FILE, "utf8").split(/\r?\n/)) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+)\s*$/);
if (m) cfg[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
}
} catch {
throw new Error(`Cannot read ${ENV_FILE}`);
}
if (!cfg.PB_URL) throw new Error("PB_URL is not set in .env.local");
if (!cfg.PB_ADMIN_EMAIL || !cfg.PB_ADMIN_PASSWORD) {
throw new Error("PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD are not set in .env.local");
}
return cfg;
}
// ---------------------------------------------------------------------------
// http
// ---------------------------------------------------------------------------
async function auth(cfg) {
const res = await fetch(`${cfg.PB_URL}/api/collections/_superusers/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
identity: cfg.PB_ADMIN_EMAIL,
password: cfg.PB_ADMIN_PASSWORD,
}),
});
if (!res.ok) {
throw new Error(`Admin auth failed: HTTP ${res.status} ${await res.text()}`);
}
const body = await res.json();
if (!body.token) throw new Error("Admin auth returned no token");
return body.token;
}
async function pbGet(token, url) {
const res = await fetch(url, { headers: { Authorization: token } });
if (!res.ok) throw new Error(`GET ${url} failed: HTTP ${res.status} ${await res.text()}`);
return res.json();
}
// PATCH, not PUT: the PUT variants 404 on this PocketBase build.
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();
}
function stripServerKeys(field) {
const { id, system, hidden, ...rest } = field;
return { ...rest, hidden: Boolean(hidden), system: false };
}
/**
* Add `field` to a collection if its 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: fields.map(stripServerKeys).concat(stripServerKeys(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";
}
// ---------------------------------------------------------------------------
// field definition
// ---------------------------------------------------------------------------
function signedCertificatesField() {
return {
name: "signed_certificates",
type: "file",
required: false,
// PocketBase validates MIME against this list. A scan or a phone photo
// must be saved as PDF first; the upload route says so in plain English
// rather than surfacing a raw PocketBase error.
mimeTypes: ["application/pdf"],
maxSelect: 1,
maxSize: MAX_BYTES,
// PDF is not previewable inline in the admin UI, and these are records the
// operator opens through the app's own document viewer.
preview: ["none"],
};
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
async function main() {
const checkOnly = process.argv.includes("--check");
const cfg = loadEnv();
console.log("");
console.log("Shareholder certificates — signed_certificates slot");
console.log(` target : ${cfg.PB_URL}`);
console.log(` mode : ${checkOnly ? "check only" : "apply"}`);
console.log("");
const token = await auth(cfg);
console.log("Applying");
await ensureField({
token,
base: cfg.url ?? cfg.PB_URL,
colId: RESOLUTIONS_COL,
field: signedCertificatesField(),
checkOnly,
label: "resolutions.signed_certificates (file, PDF)",
});
// ---- verify ----
console.log("");
console.log("Verification");
const res = await pbGet(token, `${cfg.PB_URL}/api/collections/${RESOLUTIONS_COL}`);
const byName = Object.fromEntries((res.fields || []).map((f) => [f.name, f]));
const cert = byName.signed_certificates;
const signedLetter = byName.signed_letter;
const generated = byName.generated_letter;
console.log(` signed_certificates : ${cert ? `${cert.type}, ${cert.maxSelect} file` : "MISSING"}`);
console.log(` signed_letter : ${signedLetter ? "present" : "MISSING"}`);
console.log(` generated_letter : ${generated ? "present" : "MISSING"}`);
const problems = [];
if (!cert) {
// In check mode this is the expected finding, not a failure — the field is
// simply not there yet. Exiting non-zero would make `--check` unusable as
// a pre-flight.
if (checkOnly) {
console.log("");
console.log("Check complete. Re-run without --check to apply.");
console.log("");
return;
}
problems.push("signed_certificates missing");
}
for (const name of ["signed_letter", "generated_letter"]) {
if (!byName[name]) problems.push(`${name} 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;
});