/* * Technische zelftest van één installatie: staat alles wat de app nodig heeft er nog, en werkt het? * * Dit is bewust géén onderdeel van `npm run health`. Dat rapport gaat over de gemeenschap — hoeveel * mensen je mag aanschrijven, of je brief beantwoord wordt — en is bedoeld om gelezen te worden door * iemand die over het product nadenkt. Dit hier gaat over de machine, en wordt gelezen door iemand * die net uitgerold heeft of die iets raars ziet. Twee vragen, twee scripts. * * Wat het nakijkt, en waarom juist dit: * * database Draait hij, en kunnen we erbij? Zonder dit werkt niets. * schema Staan alle tabellen uit db/schema.sql er echt? Dit vangt de gevaarlijkste uitrolfout * die er is: nieuwe code op een oude database. Die valt pas om bij de eerste gebruiker * die de nieuwe kolom raakt, en dan met een 500 waar niemand bij staat te kijken. * betaalketen Sleutel → token → rechten bij Google. Dit is de stilste storing van allemaal: is de * gratis bak leeg en hapert de kassa, dan komt er geen enkel nieuw lid meer binnen, de * app ziet er verder kerngezond uit, en het enige spoor is een logregel. * promotieteam Staat het slot op /promotieteam en is de ledenlijst bereikbaar? Die database staat * los van de app (zie server/promotie.js) en komt dus ook niet mee in het schema hierboven. * * Afsluitcode 0 als alles in orde is, 1 als er iets kapot is — zo is hij ook na een uitrol te * gebruiken zonder de uitvoer te lezen. * * npm run doctor * cd /opt/liefdesbrieven && set -a && . ./.env && set +a && npm run doctor # op de server */ import { readFileSync, readdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { pool } from "../server/db.js"; import { billingSelfTest } from "../server/billing.js"; import { promotieSelfTest, promotiePoolEnd } from "../server/promotie.js"; import { FREE_ACCOUNTS } from "../src/constants.js"; const HIER = dirname(fileURLToPath(import.meta.url)); /* ---------- opmaak ---------- */ const uitkomsten = []; const meld = (naam, staat, tekst) => uitkomsten.push({ naam, staat, tekst }); /* null = niet vast te stellen (bijvoorbeeld doordat het schema stuk is). Alleen `true` rechtvaardigt de uitspraak dat er niemand meer binnenkomt — anders zou dit script gaan gissen. */ let bakLeeg = null; /* ---------- database en schema ---------- */ async function database() { let versie; try { const { rows } = await pool.query("select current_setting('server_version') as v, current_database() as db"); versie = rows[0]; } catch (err) { meld("database", "kapot", `geen verbinding: ${err.message}`); return false; } meld("database", "goed", `postgres ${versie.v}, database ${versie.db}`); /* * De waarheid over het schema staat in db/schema.sql, niet in een lijstje hier: dan kan het niet * verouderen. Wat er in het bestand `create table` heet, hoort in de database te staan. */ const sql = readFileSync(join(HIER, "..", "db", "schema.sql"), "utf8"); const verwacht = [...sql.matchAll(/^create table (?:if not exists )?(\w+)/gim)].map((m) => m[1]); const { rows } = await pool.query( `select table_name from information_schema.tables where table_schema = 'public'` ); const aanwezig = new Set(rows.map((r) => r.table_name)); const ontbreekt = verwacht.filter((t) => !aanwezig.has(t)); if (ontbreekt.length) { meld("schema", "kapot", `${ontbreekt.length} van de ${verwacht.length} tabellen ontbreken: ${ontbreekt.join(", ")}` + ` — de code loopt vóór op de database; draai db:migrate (op een ontwikkelmachine: db:setup)`); return false; } meld("schema", "goed", `alle ${verwacht.length} tabellen uit db/schema.sql staan er`); /* * Openstaande migraties zijn dezelfde uitrolfout als een ontbrekende tabel — nieuwe code op een * oude database — maar fijnmaziger: ook een kolom of index die alleen nog in db/migrations/ * bestaat valt pas om bij de eerste gebruiker die hem raakt. De tabellencheck hierboven ziet * dat niet; deze wel. */ let migraties = []; try { migraties = readdirSync(join(HIER, "..", "db", "migrations")).filter((f) => f.endsWith(".sql")); } catch { /* map bestaat nog niet: er is geen migratie geschreven */ } const { rows: klaar } = await pool.query("select filename from schema_migrations"); const gedaan = new Set(klaar.map((r) => r.filename)); const open = migraties.filter((f) => !gedaan.has(f)).sort(); if (open.length) { meld("migraties", "kapot", `${open.length} openstaand: ${open.join(", ")} — draai db:migrate`); return false; } meld("migraties", "goed", migraties.length ? `alle ${migraties.length} uitgevoerd` : "geen migraties — db/schema.sql is de hele stand"); return true; } /* ---------- betaalketen ---------- */ async function betalen(schemaGoed) { /* * Of de kassa dragend is, hangt af van de gratis bak. Zolang daar plekken in zitten komt een nieuw * lid er ook zonder betalen in, en is een stukke keten wel een probleem maar geen dichte deur. Dat * onderscheid bepaalt of dit script hierop faalt of alleen waarschuwt. */ if (schemaGoed) { try { const { rows } = await pool.query("select granted from signup_slots"); const uit = rows[0]?.granted ?? 0; bakLeeg = uit >= FREE_ACCOUNTS; meld("gratis plekken", "goed", `${uit} van de ${FREE_ACCOUNTS} uitgegeven` + (bakLeeg ? " — de bak is leeg, dus betalen is de enige weg naar binnen" : " — er kan nog gratis in")); } catch (err) { meld("gratis plekken", "kapot", err.message); } } const t = await billingSelfTest(); if (t.ok) { meld("betaalketen", "goed", t.uitleg); return true; } /* Kapot én de bak leeg = niemand komt er meer in. Kapot met plekken over = repareren voor het zover is. */ meld("betaalketen", bakLeeg === false ? "let op" : "kapot", `${t.stap}: ${t.uitleg}`); return bakLeeg === false; } /* ---------- draaien ---------- */ try { const schemaGoed = await database(); const betaalGoed = await betalen(schemaGoed); /* De ledenlijst van het promotieteam: eigen database, eigen slot, dus een eigen regel */ const p = await promotieSelfTest(); meld("promotieteam", p.ok ? "goed" : p.hard ? "kapot" : "let op", p.uitleg); const breed = Math.max(...uitkomsten.map((u) => u.naam.length)); const merk = { goed: " ", "let op": "! ", kapot: "X " }; console.log("Liefdesbrieven — technische zelftest"); console.log("─".repeat(78)); for (const u of uitkomsten) console.log(`${merk[u.staat]}${u.naam.padEnd(breed)} : ${u.tekst}`); console.log(""); const kapot = uitkomsten.filter((u) => u.staat === "kapot"); if (kapot.length) { console.log(`KAPOT: ${kapot.map((u) => u.naam).join(", ")}. Zie hierboven wat eraan scheelt.`); /* Alleen zeggen als we het wéten: met een kapot schema is de bak niet af te lezen. */ if (!betaalGoed && bakLeeg === true && kapot.some((u) => u.naam === "betaalketen")) console.log(`Zolang dit zo staat komt er NIEMAND meer binnen: iedereen die zich aanmeldt loopt op de kassa vast.`); process.exitCode = 1; } else if (uitkomsten.some((u) => u.staat === "let op")) { console.log(`Alles draait, maar er staat iets open. Nu te repareren zonder dat iemand er last van heeft.`); } else { console.log(`Alles in orde.`); } } finally { await pool.end(); await promotiePoolEnd(); }