Een dating-app op basis van brieven, niet van gezichten!

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

7.4 kB Ruw bestand
/*
 * 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();
}