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.

32.3 kB Ruw bestand
import { Hono } from "hono";
import { createHash, timingSafeEqual } from "node:crypto";
import pg from "pg";
import { rateLimiter, clientIp, inWords } from "./ratelimit.js";
import { esc, page, SERIF } from "./huisstijl.js";
import { brief, vel, inhoud, letterlijk } from "./mail-html.js";
import { sendMail, sendBatch, mailReady, streamSuppressions, unsuppress } from "./mail.js";
import { STORE_TITLE, STORE_SHORT, STORE_KERN, STORE_AGE, STORE_URL } from "../src/content/store.js";

/*
 * Het promotieteam: de vrienden die vrijwillig mond-tot-mondreclame maken. Eén afgeschermde
 * pagina waar de beheerder de ledenlijst bijhoudt en het team mailt — server-gerenderde HTML,
 * geen JavaScript, geen framework, op hetzelfde vel als de publieke pagina's (server/huisstijl.js).
 *
 * Drie dingen die dit bewust níet is:
 *   * Geen deel van de app-database. Die wordt bij elke schemawijziging opnieuw aangemaakt en
 *     haar gegevens zijn wegwerpbaar; de namen van je vrienden zijn dat niet. Eigen pool, eigen
 *     database (PROMOTIE_DATABASE_URL), eigen setup: db/promotie/.
 *   * Geen meetinstrument. Er is geen quotum en geen controle, dus er wordt niets geteld en
 *     niets toegeschreven — geen aanbrengcodes, geen koppeling met wie zich aanmeldde.
 *   * Geen publieke pagina. Er is geen link heen, er staat noindex op, en zonder het wachtwoord
 *     kom je er niet in. Vrienden vullen hier niets in; de beheerder typt ze zelf.
 *
 * Toegang: één wachtwoord uit PROMOTIE_PASSWORD, via HTTP Basic (de gebruikersnaam doet niet
 * mee — vul in wat je wilt). Het wachtwoord staat met opzet níet in deze broncode: de
 * repository is openbaar. Zonder die variabele blijft de pagina dicht in plaats van open.
 *
 * De post gaat via Postmark (server/mail.js), vanaf een adres op het platformdomein zodat de
 * handtekening uitlijnt. Eén mail per persoon, met een eigen aanhef — geen bcc-blok waarin
 * iedereen elkaars adres ziet. Het afmelden onder de groepspost is van Postmark (zie `staart`);
 * wie klikt komt hier vanzelf op Gestopt te staan.
 */

export const PROMOTIE_PATH = "/promotieteam";

/*
 * De afzender. Niet `yesreply@` (dat is het adres voor systeempost en landt bij de
 * klantenservice) en niet `privacy@` (dat is het AVG-loket): post over het promotieteam is de
 * marketingkant, dus `marketing@`, dat naar Platforms/Liefdesbrieven/Marketing sorteert. Ook
 * niet `nieuwsbrief@` — dat sorteert naar dezelfde map, maar hoort bij de nieuwsbrief zelf en
 * blijft daarvoor vrij. Het persoonlijke zit in de weergavenaam en in de tekst, niet in het adres.
 */
const FROM = process.env.PROMOTIE_FROM ?? "Bram van Liefdesbrieven <marketing@liefdesbrieven.bramluiken.org>";
/* Groepspost hoort in een broadcast-stream, post aan één persoon in de transactionele */
const STREAM_GROEP = process.env.PROMOTIE_STREAM ?? "liefdesbrieven-promotieteam";
const STREAM_EEN = "outbound";

const pool = new pg.Pool({
  connectionString: process.env.PROMOTIE_DATABASE_URL ?? "postgres://postgres@localhost:5432/promotie",
});
const query = (text, params) => pool.query(text, params);

/* ---------- Toegang ---------- */

/* Constante tijd, en via sha256 zodat de vergelijking niet op lengte kan verschillen */
const sameSecret = (a, b) =>
  timingSafeEqual(createHash("sha256").update(a).digest(), createHash("sha256").update(b).digest());

/*
 * Twee remmen op het raden, en dat is met opzet niet één. Deze pagina heeft één wachtwoord en verder
 * niets — geen tweede factor, geen account — en beheert de namen, adressen en telefoonnummers van
 * vrienden én de knop die het hele team mailt. Dan hoort het slot niet af te hangen van of het
 * adres van de bezoeker klopt: dat is precies wat er een keer misging (zie clientIp in
 * server/ratelimit.js), en toen was dit de plek waar het het meest kostte.
 *
 * De tweede teller loopt over álle pogingen samen, ongeacht waar ze vandaan komen. Dat kan een
 * aanhoudende aanval de pagina een kwartier lang dichtzetten, ook voor de beheerder — en dat is hier
 * de goede kant om te falen: dit is een ledenlijst die kan wachten, en niet de app.
 */
const guessByIp = rateLimiter({ windowMs: 15 * 60_000, max: 10 });
const guessTotaal = rateLimiter({ windowMs: 15 * 60_000, max: 30 });

const passwordFrom = (c) => {
  const m = /^Basic\s+(\S+)$/i.exec(c.req.header("authorization") ?? "");
  if (!m) return null;
  const decoded = Buffer.from(m[1], "base64").toString("utf8");
  const i = decoded.indexOf(":");
  return i === -1 ? null : decoded.slice(i + 1);
};

const promotie = new Hono();

promotie.use(async (c, next) => {
  c.header("X-Robots-Tag", "noindex, nofollow");
  c.header("Cache-Control", "no-store");

  const secret = process.env.PROMOTIE_PASSWORD ?? "";
  if (!secret) {
    return c.text("Deze pagina is niet ingesteld: PROMOTIE_PASSWORD ontbreekt in de omgeving.", 503);
  }

  const given = passwordFrom(c);
  if (given !== null && sameSecret(given, secret)) {
    /*
     * Een formulier dat vanaf een andere site wordt verstuurd, stuurt de Basic-inloggegevens
     * van de browser gewoon mee. Vandaar deze controle: een POST hoort van deze pagina zelf
     * te komen. Browsers zetten Origin op elke POST; is hij er niet, dan komt het niet uit
     * een pagina (curl, een app) en dat mag.
     */
    if (c.req.method === "POST") {
      const origin = c.req.header("origin");
      const host = c.req.header("host");
      if (origin && new URL(origin).host !== host) return c.text("Verkeerde herkomst", 403);
    }
    return next();
  }

  /*
   * Alleen een verkeerd wachtwoord telt als poging. Een verzoek zónder Authorization-header is
   * de gewone eerste helft van HTTP Basic — de browser vraagt, krijgt 401, zet het venster neer —
   * en wie dat meetelt sluit zichzelf buiten na tien keer de pagina openen.
   */
  if (given !== null) {
    const perAdres = guessByIp.hit(`promotie:${clientIp(c)}`);
    const samen = guessTotaal.hit("promotie");
    if (!perAdres.ok || !samen.ok) {
      const wacht = Math.max(perAdres.retryAfterSec, samen.retryAfterSec);
      c.header("Retry-After", String(Math.max(1, Math.ceil(wacht))));
      return c.text(`Te veel pogingen — probeer het ${inWords(wacht)} opnieuw`, 429);
    }
  }
  c.header("WWW-Authenticate", 'Basic realm="Promotieteam", charset="UTF-8"');
  return c.text("Wachtwoord vereist", 401);
});

/* ---------- De pagina ---------- */

/*
 * Hetzelfde vel als de publieke pagina's (server/huisstijl.js), met de velden van een beheerpagina
 * erop. Alleen wat een formulier eigen is staat hier; palet, letters en donkere modus komen daar
 * vandaan, en `noindex` gaat als meta mee naast de header die de middleware zet.
 */
const BEHEER_CSS = `
.vel{max-width:760px}
h2{font-size:1.1rem;margin:30px 0 10px}
.melding{margin:0 0 16px;padding:10px 12px;background:var(--panel-2);border-left:3px solid var(--accent)}
.fout{border-left-color:var(--alert);background:color-mix(in srgb,var(--alert) 12%,var(--panel))}
form.rij{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:0 0 10px;padding:12px;border:1px solid var(--rule)}
form.rij.gestopt{background:var(--paper)}
form.rij .breed{grid-column:1 / -1}
input,textarea{width:100%;padding:7px 9px;border:1px solid var(--rule);background:var(--paper);
  color:var(--ink);font:inherit;font-size:15px}
textarea{min-height:150px;resize:vertical;line-height:1.5;font-family:${SERIF}}
input:focus,textarea:focus{outline:2px solid var(--accent);outline-offset:-1px}
.knoppen{grid-column:1 / -1;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
button{padding:7px 14px;border:1px solid var(--knop);background:var(--knop);color:var(--knop-ink);
  font:inherit;font-size:15px;cursor:pointer}
button.stil{background:transparent;color:var(--accent);border-color:var(--accent)}
button[disabled]{opacity:.5;cursor:not-allowed}
a.stil{padding:7px 14px;border:1px solid var(--rule);color:var(--mute);text-decoration:none}
.knoppen .sinds{margin-left:auto}
.brief{padding:16px 18px;background:var(--paper);border:1px solid var(--rule);font-size:15px}
/* De voorbeelden dragen de kleuren van de post zelf; op deze pagina hoort de link die van hier te volgen */
.brief a{color:var(--accent)!important}
.post{padding:20px 16px;background:var(--paper);border:1px solid var(--rule);border-top:0}
.kop{padding:10px 18px;background:var(--panel-2);border:1px solid var(--rule);border-bottom:0;font-size:14px}
table{width:100%;border-collapse:collapse;font-size:14px}
td,th{text-align:left;padding:6px 8px;border-bottom:1px solid var(--rule);vertical-align:top}
@media (max-width:560px){form.rij{grid-template-columns:1fr}.knoppen .sinds{margin-left:0;width:100%}}
`;

const beheerPagina = (title, body) => page(title, body, { css: BEHEER_CSS, robots: "noindex, nofollow" });

const MELDINGEN = {
  toegevoegd: "Toegevoegd aan het team.",
  gemaild: "Toegevoegd, en de welkomstmail is onderweg.",
  welkom: "Welkomstmail verstuurd.",
  opgeslagen: "Opgeslagen.",
  sjabloon: "De welkomstmail is bijgewerkt.",
  gestopt: "Op gestopt gezet — de gegevens blijven staan.",
  terug: "Doet weer mee.",
  gewist: "Gewist.",
};

/*
 * Onder elk tekstvak dat post wordt. Kort, want het is geen handleiding: wie niets van opmaak weet
 * typt gewoon zinnen en krijgt zinnen. Zie server/mail-html.js voor wat er precies herkend wordt.
 */
const OPMAAK_UITLEG = `<p class="mute">Opmaak mag: <code>**vet**</code>, <code>*cursief*</code>,
<code>[tekst](https://…)</code>, en een opsomming met een <code>-</code> aan het begin van de regel.
Wie geen opmaak leest, krijgt precies de tekens die je intypte.</p>`;

const veld = (naam, label, waarde, extra = "") =>
  `<input name="${naam}" value="${esc(waarde)}" placeholder="${esc(label)}" aria-label="${esc(label)}" ${extra}>`;

/*
 * Eén lid: een formulier op zichzelf, zodat bewerken en opslaan zonder JavaScript kan. `kapot` is
 * de verzameling adressen waar Postmark niets meer heen krijgt (hard bounce) — die staan er
 * bijgeschreven, want een adres met een tikfout ziet er in de lijst precies goed uit.
 */
const lidRij = (p, kapot = new Set()) => `
<form class="rij ${p.left_on ? "gestopt" : ""}" method="post" action="${PROMOTIE_PATH}/${p.id}">
  ${veld("name", "Naam", p.name, "required maxlength=80")}
  ${veld("email", "E-mail", p.email ?? "", "type=email maxlength=254")}
  ${veld("phone", "Telefoon", p.phone ?? "", "maxlength=40")}
  ${veld("note", "Notitie", p.note, "maxlength=200")}
  <div class="knoppen">
    <button name="actie" value="opslaan">Opslaan</button>
    ${p.left_on
      ? `<button class="stil" name="actie" value="terug">Doet weer mee</button>`
      : `<button class="stil" name="actie" value="gestopt">Gestopt</button>`}
    ${!p.left_on && p.email && !p.welcomed_at
      ? `<button class="stil" name="actie" value="welkom">Welkom sturen</button>`
      : ""}
    <a class="stil" href="${PROMOTIE_PATH}/${p.id}/wissen">Wissen</a>
    <span class="mute mono sinds">${p.email && kapot.has(p.email.toLowerCase())
      ? `<strong>adres bestaat niet</strong> · `
      : ""}${p.left_on
      ? `${esc(p.sinds)} – ${esc(p.tot)}${p.unsubscribed_at ? " · zelf afgemeld" : ""}`
      : `sinds ${esc(p.sinds)}${p.email ? (p.welcomed_at ? " · welkom ✓" : " · geen welkomstmail") : ""}`}</span>
  </div>
</form>`;

/* ---------- De post ---------- */

/*
 * `{naam}` is de enige invulling: de aanhef. Zo blijft een mail schrijven het schrijven van een brief.
 * In de HTML-versie gaat die naam langs `letterlijk`, zodat iemand die "Jan [de] *Vries*" heet daar
 * geen opmaak van maakt; in de tekstversie staat hij zoals hij in de lijst staat.
 */
const vulIn = (tekst, p, hoe = (s) => s) => tekst.replaceAll("{naam}", hoe(p.name));

/* In de tekstversie scheidt een streepje wat er automatisch onder komt; in de HTML doet de lijn dat. */
const metStaart = (tekst, staart) => `${tekst}\n\n—\n${staart}`;

/*
 * De staart onder de groepspost. Bij een broadcast-stream regelt Postmark het afmelden zelf: hun
 * link, hun headers, en hun eigen regel eronder — dat laatste is alleen uit te zetten met
 * toestemming voor "custom unsubscribe handling", die dit account niet heeft. Vandaar hun
 * placeholder in onze eigen zin: dan staat er één nette regel in plaats van twee links onder
 * elkaar. Wie klikt komt bij Postmark op de afmeldlijst; `synchroniseerAfmeldingen()` haalt die
 * op en zet diezelfde persoon hier op Gestopt, zodat de lijst klopt.
 *
 * En daarom staat er "uit het promotieteam" en niet "deze post niet meer": afmelden ís hier uit
 * het team stappen. Een zin die minder belooft dan er gebeurt, is een zin die niet klopt.
 */
const AFMELD = "{{{ pm:unsubscribe }}}";   // Postmark vult hier de persoonlijke link in

/*
 * Twee versies van dezelfde zin, en dat verschil is precies waarvoor de HTML-versie bestaat: de
 * afmeld-URL van Postmark is een regel of vijf lang, dus in de tekst staat hij voluit en in de brief
 * achter woorden. Verder is de opmaak wat platte tekst ook doet.
 */
const AFMELD_TEKST = `Wil je uit het promotieteam? Meld je gerust af: ${AFMELD}`;
const AFMELD_BRIEF = `Wil je uit het promotieteam? [Meld je gerust af](${AFMELD}).`;

const groepsBericht = (p, subject, body) => ({
  from: FROM,
  to: p.email,
  subject,
  text: metStaart(vulIn(body, p), AFMELD_TEKST),
  html: brief(vulIn(body, p, letterlijk), AFMELD_BRIEF),
  stream: STREAM_GROEP,
});

/*
 * De terugweg van die afmeldknop, en hij gaat twee kanten op. Afmelden en een spamklacht
 * betekenen allebei "hier gaat geen post meer heen", en dat is precies wat Gestopt hier betekent.
 * Maar Postmark zet op zijn afmeldpagina meteen "did you do that by accident? click here to
 * resubscribe" — een misklik herstel je dus in dezelfde seconde, en dan hoort die persoon hier
 * gewoon weer mee te doen zonder dat iemand het met de hand rechtzet. Vandaar `unsubscribed_at`:
 * alleen wie via de mail vertrok komt via de mail terug. Wie jij op Gestopt zette staat niet op
 * die lijst en blijft dus staan waar hij staat.
 *
 * Een hard bounce telt niet als vertrek: dat is geen besluit maar een adres dat niet bestaat, en
 * dat hoort in beeld te komen zodat je het verbetert. Loopt het ophalen mis (Postmark plat, geen
 * token), dan is dat geen reden om de pagina te laten vallen: je krijgt de lijst zoals hij hier
 * bekend is.
 */
async function synchroniseerAfmeldingen() {
  const lijst = await streamSuppressions(STREAM_GROEP);
  const afgemeld = lijst.filter((s) => s.reden === "ManualSuppression" || s.reden === "SpamComplaint").map((s) => s.email);

  await query(`
    update promoters set left_on = current_date, unsubscribed_at = now()
     where left_on is null and lower(email) = any($1)`, [afgemeld]);

  /* Weer van de lijst af (zelf hersteld, of door jou bij Postmark weggehaald) → weer in het team */
  await query(`
    update promoters set left_on = null, unsubscribed_at = null
     where unsubscribed_at is not null and (email is null or lower(email) <> all($1))`, [afgemeld]);

  return new Set(lijst.filter((s) => s.reden === "HardBounce").map((s) => s.email));
}

/*
 * Wat er onder elke welkomstmail komt: waar het over gaat, in de woorden van de winkelvermelding
 * (src/content/store.js). Niet in de bewerkbare tekst hierboven maar eronder geplakt, en dat is
 * met opzet: wát Liefdesbrieven is hoort overal hetzelfde te zijn — winkel, website, deze post —
 * terwijl de zinnen erbóven van jou zijn en per keer mogen verschillen. Verandert de vermelding,
 * dan verandert dit mee, zonder dat iemand een mailtekst hoeft na te lopen.
 *
 * Iemand die het gaat navertellen heeft geen volledige beschrijving nodig maar de punten waarop
 * dit van de rest verschilt; vandaar STORE_KERN en niet STORE_FULL.
 */
const WELKOM_STAART = `Waar het over gaat, voor als je het navertelt:

${STORE_TITLE} — ${STORE_SHORT.toLowerCase()}

${STORE_KERN.map((r) => `- ${r}`).join("\n\n")}

Meer staat op ${STORE_URL}
${STORE_AGE}`;

/*
 * De welkomstmail: aan één persoon, uitgelokt door het toevoegen — dus de transactionele stream.
 * De staart is met streepjes geschreven en niet met bolletjes, want dat is waar de opmaak op let: in
 * de brief wordt het een opsomming, in de tekstversie blijft het een streepje en dat leest zoals het
 * altijd al las.
 */
async function stuurWelkom(p) {
  const { rows } = await query("select subject, body from welcome");
  const { subject, body } = rows[0];
  await sendMail({
    from: FROM,
    to: p.email,
    subject,
    text: metStaart(vulIn(body, p), WELKOM_STAART),
    html: brief(vulIn(body, p, letterlijk), WELKOM_STAART),
    stream: STREAM_EEN,
  });
  await query("update promoters set welcomed_at = now() where id = $1", [p.id]);
}

/* ---------- De lijst ---------- */

const LEDEN = `
  select id, name, email, phone, note, left_on, welcomed_at, unsubscribed_at,
         to_char(joined_on, 'DD-MM-YYYY') as sinds,
         to_char(left_on,   'DD-MM-YYYY') as tot
    from promoters
   order by left_on nulls first, lower(name)
`;

async function lijstHtml({ melding = "", fout = "", nieuw = {}, mailing = {} } = {}) {
  /*
   * Eerst de afmeldingen ophalen, dan pas de lijst lezen — anders zie je iemand die net geklikt
   * heeft nog een paginaweergave lang tussen de actieve leden staan.
   */
  let kapot = new Set(), syncFout = "";
  if (mailReady()) {
    try {
      kapot = await synchroniseerAfmeldingen();
    } catch (err) {
      syncFout = `Afmeldingen niet opgehaald bij Postmark: ${err.message}`;
    }
  }

  const [{ rows }, { rows: sjabloon }, { rows: post }] = await Promise.all([
    query(LEDEN),
    query("select subject, body from welcome"),
    query("select id, subject, aantal, mislukt, to_char(sent_at, 'DD-MM-YYYY HH24:MI') as wanneer from mailings order by sent_at desc limit 8"),
  ]);
  const actief = rows.filter((p) => !p.left_on);
  const gestopt = rows.filter((p) => p.left_on);
  const teMailen = actief.filter((p) => p.email);

  return beheerPagina(
    "Promotieteam",
    `<h1>Promotieteam</h1>
<p class="mute">De vrienden die mond-tot-mondreclame maken. Er is geen quotum en er wordt niets bijgehouden
behalve dit: wie meedoet, en hoe je diegene bereikt als het feest er is.</p>
${fout ? `<p class="melding fout">${esc(fout)}</p>` : ""}
${melding ? `<p class="melding">${esc(melding)}</p>` : ""}
${mailReady() ? "" : `<p class="melding fout">Er kan geen post uit: POSTMARK_SERVER_TOKEN ontbreekt in de omgeving.</p>`}
${syncFout ? `<p class="melding fout">${esc(syncFout)}</p>` : ""}

<h2>Iemand toevoegen</h2>
<form class="rij" method="post" action="${PROMOTIE_PATH}">
  ${veld("name", "Naam", nieuw.name ?? "", "required maxlength=80 autofocus")}
  ${veld("email", "E-mail", nieuw.email ?? "", "type=email maxlength=254")}
  ${veld("phone", "Telefoon", nieuw.phone ?? "", "maxlength=40")}
  ${veld("note", "Notitie", nieuw.note ?? "", "maxlength=200")}
  <div class="knoppen"><button>Toevoegen</button>
  <span class="mute mono sinds">met een e-mailadres gaat de welkomstmail er meteen uit</span></div>
</form>

<h2>Doet mee${actief.length ? ` (${actief.length})` : ""}</h2>
${actief.length ? actief.map((p) => lidRij(p, kapot)).join("") : `<p class="mute">Nog niemand.</p>`}

<h2>Mail het team</h2>
<p class="mute">Gaat als ${teMailen.length} losse ${teMailen.length === 1 ? "mail" : "mails"} de deur uit — ieder zijn
eigen, niemand ziet een ander adres. Je krijgt eerst te zien hoe hij eruitziet.</p>
<form class="rij" method="post" action="${PROMOTIE_PATH}/mailing">
  <div class="breed">${veld("subject", "Onderwerp", mailing.subject ?? "", "required maxlength=120")}</div>
  <div class="breed"><textarea name="body" required placeholder="Hoi {naam},&#10;&#10;…" aria-label="Bericht">${esc(mailing.body ?? "")}</textarea></div>
  <div class="knoppen"><button ${teMailen.length ? "" : "disabled"}>Voorbeeld</button>
  <span class="mute mono sinds">{naam} wordt de naam van de ontvanger</span></div>
</form>
${OPMAAK_UITLEG}

<h2>De welkomstmail</h2>
<p class="mute">Gaat automatisch naar wie je toevoegt, als er een e-mailadres bij staat. Eén mail, geen
afmeldlink — dit is post aan één persoon.</p>
<form class="rij" method="post" action="${PROMOTIE_PATH}/welkomstmail">
  <div class="breed">${veld("subject", "Onderwerp", sjabloon[0].subject, "required maxlength=120")}</div>
  <div class="breed"><textarea name="body" required aria-label="Welkomstmail">${esc(sjabloon[0].body)}</textarea></div>
  <div class="knoppen"><button class="stil">Tekst opslaan</button></div>
</form>
${OPMAAK_UITLEG}
<p class="mute">Hieronder komt dit er vast onder — wat de app is, in de woorden van de
winkelvermelding. Die tekst staat in de code (<span class="mono">src/content/store.js</span>), zodat
winkel, website en deze post niet uit elkaar gaan lopen.</p>
<div class="brief">${inhoud(WELKOM_STAART)}</div>

${post.length ? `<h2>Verstuurd</h2>
<table>
<tr><th>Wanneer</th><th>Onderwerp</th><th>Aan</th></tr>
${post.map((m) => `<tr><td class="mono">${esc(m.wanneer)}</td><td>${esc(m.subject)}</td><td>${m.aantal}${
  m.mislukt ? `<br><span class="mute">mislukt: ${esc(m.mislukt)}</span>` : ""
}</td></tr>`).join("")}
</table>` : ""}

${gestopt.length ? `<h2>Gestopt (${gestopt.length})</h2>${gestopt.map((p) => lidRij(p, kapot)).join("")}` : ""}`
  );
}

/* ---------- Invoer ---------- */

/* Lege velden worden null: "" is geen telefoonnummer, en de check in het schema rekent erop */
const leeg = (v) => {
  const s = typeof v === "string" ? v.trim() : "";
  return s === "" ? null : s;
};

const uitFormulier = async (c) => {
  const body = await c.req.parseBody();
  return {
    actie: typeof body.actie === "string" ? body.actie : "opslaan",
    name: leeg(body.name),
    email: leeg(body.email)?.toLowerCase() ?? null,
    phone: leeg(body.phone),
    note: leeg(body.note) ?? "",
  };
};

/* Wat er mis kan zijn, in gewone zinnen — de checks in het schema vangen de rest af */
const fouten = (v) => {
  if (!v.name) return "Vul een naam in.";
  if (v.name.length > 80) return "Die naam is te lang (maximaal 80 tekens).";
  if (!v.email && !v.phone) return "Vul een e-mailadres of een telefoonnummer in — anders kun je niemand uitnodigen.";
  if (v.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email)) return "Dat e-mailadres klopt niet.";
  return null;
};

/* ---------- Routes: de leden ---------- */

/* Object.hasOwn, want `?m=constructor` haalt anders iets uit de prototypeketen op */
promotie.get("/", async (c) => {
  const m = c.req.query("m") ?? "";
  return c.html(await lijstHtml({ melding: Object.hasOwn(MELDINGEN, m) ? MELDINGEN[m] : "" }));
});

promotie.post("/", async (c) => {
  const v = await uitFormulier(c);
  const fout = fouten(v);
  if (fout) return c.html(await lijstHtml({ fout, nieuw: v }), 400);
  const { rows } = await query(
    "insert into promoters (name, email, phone, note) values ($1, $2, $3, $4) returning id, name, email",
    [v.name, v.email, v.phone, v.note]);

  if (!rows[0].email) return c.redirect(`${PROMOTIE_PATH}?m=toegevoegd`, 303);
  try {
    await stuurWelkom(rows[0]);
  } catch (err) {
    /* Het lid staat er nu; alleen de post ging niet weg. Dat hoort te zien te zijn, met de knop ernaast. */
    return c.html(await lijstHtml({
      fout: `${v.name} staat in de lijst, maar de welkomstmail ging niet weg: ${err.message}. Met "Welkom sturen" probeer je het opnieuw.`,
    }));
  }
  return c.redirect(`${PROMOTIE_PATH}?m=gemaild`, 303);
});

promotie.post("/:id{[0-9]+}", async (c) => {
  const id = c.req.param("id");
  const v = await uitFormulier(c);

  if (v.actie === "gestopt" || v.actie === "terug") {
    /*
     * Stoppen en terugkomen raken alleen left_on, niet de velden uit het formulier: je klikt
     * "Gestopt" soms met een half getypte wijziging in beeld, en die hoort dan niet stilletjes
     * mee te gaan. Terugkomen wist de datum weer — daar is geen tweede rij voor nodig.
     */
    const { rows } = await query("select name, email, unsubscribed_at from promoters where id = $1", [id]);
    if (!rows.length) return c.html(await lijstHtml({ fout: "Die is er niet (meer)." }), 404);
    const p = rows[0];

    /*
     * Vertrok iemand via de afmeldknop, dan staat hij ook op de lijst bij Postmark. Hem hier
     * terugzetten zonder dat daar weg te halen levert een lid op waar geen post heen kan — en de
     * eerstvolgende synchronisatie zou hem meteen weer als vertrokken zien. Lukt dat weghalen
     * niet (een spamklacht mag niet ongedaan gemaakt worden), dan blijft hij staan waar hij
     * staat, met de reden erbij. Dat is eerlijker dan een naam in de lijst die niets ontvangt.
     */
    if (v.actie === "terug" && p.unsubscribed_at && p.email) {
      try {
        const [r] = await unsuppress(STREAM_GROEP, [p.email]);
        if (r && !r.ok) return c.html(await lijstHtml({
          fout: `${p.name} staat bij Postmark op de afmeldlijst en die is niet weg te halen: ${r.fout}. Zolang dat zo is komt er geen post aan, dus laat ik het op Gestopt staan.`,
        }));
      } catch (err) {
        return c.html(await lijstHtml({ fout: `De afmelding bij Postmark kon niet weggehaald worden: ${err.message}` }));
      }
    }

    await query(v.actie === "gestopt"
      ? "update promoters set left_on = current_date where id = $1"
      : "update promoters set left_on = null, unsubscribed_at = null where id = $1", [id]);
    return c.redirect(`${PROMOTIE_PATH}?m=${v.actie}`, 303);
  }

  if (v.actie === "welkom") {
    const { rows } = await query("select id, name, email from promoters where id = $1", [id]);
    if (!rows.length || !rows[0].email) return c.html(await lijstHtml({ fout: "Die is er niet (meer), of heeft geen e-mailadres." }), 404);
    try {
      await stuurWelkom(rows[0]);
    } catch (err) {
      return c.html(await lijstHtml({ fout: `De welkomstmail ging niet weg: ${err.message}` }));
    }
    return c.redirect(`${PROMOTIE_PATH}?m=welkom`, 303);
  }

  const fout = fouten(v);
  if (fout) return c.html(await lijstHtml({ fout }), 400);
  const { rowCount } = await query(
    "update promoters set name = $2, email = $3, phone = $4, note = $5 where id = $1",
    [id, v.name, v.email, v.phone, v.note]);
  if (!rowCount) return c.html(await lijstHtml({ fout: "Die is er niet (meer)." }), 404);
  return c.redirect(`${PROMOTIE_PATH}?m=opgeslagen`, 303);
});

/*
 * Wissen in twee stappen. Zonder JavaScript is er geen "weet je het zeker?"-venster, en één
 * misklik hoort geen naam te kosten. Wie er echt uit wil (of het niet wilde) is hiermee weg —
 * voor iemand die alleen even stopt is "Gestopt" de betere knop.
 */
promotie.get("/:id{[0-9]+}/wissen", async (c) => {
  const id = c.req.param("id");
  const { rows } = await query("select name from promoters where id = $1", [id]);
  if (!rows.length) return c.html(await lijstHtml({ fout: "Die is er niet (meer)." }), 404);
  return c.html(beheerPagina("Wissen — Promotieteam", `
<h1>${esc(rows[0].name)} wissen?</h1>
<p>De gegevens verdwijnen dan helemaal. Wil je alleen dat diegene niet meer meedoet, ga dan terug
en gebruik <strong>Gestopt</strong> — dan blijft de naam staan.</p>
<form class="rij" method="post" action="${PROMOTIE_PATH}/${esc(id)}/wissen">
  <div class="knoppen">
    <button>Ja, wissen</button>
    <a class="stil" href="${PROMOTIE_PATH}">Nee, terug</a>
  </div>
</form>`));
});

promotie.post("/:id{[0-9]+}/wissen", async (c) => {
  await query("delete from promoters where id = $1", [c.req.param("id")]);
  return c.redirect(`${PROMOTIE_PATH}?m=gewist`, 303);
});

/* ---------- Routes: de post ---------- */

promotie.post("/welkomstmail", async (c) => {
  const body = await c.req.parseBody();
  const subject = leeg(body.subject), tekst = leeg(body.body);
  if (!subject || !tekst) return c.html(await lijstHtml({ fout: "De welkomstmail heeft een onderwerp en een tekst nodig." }), 400);
  await query("update welcome set subject = $1, body = $2", [subject, tekst]);
  return c.redirect(`${PROMOTIE_PATH}?m=sjabloon`, 303);
});

/*
 * Groepspost gaat over twee stappen: eerst zien wat je stuurt, dan pas versturen. Post is niet
 * terug te halen, en de mail die je in het voorbeeld leest is letterlijk de mail die de eerste
 * op de lijst krijgt — met zijn naam erin en met de afmeldlink eronder.
 */
promotie.post("/mailing", async (c) => {
  const body = await c.req.parseBody();
  const subject = leeg(body.subject), tekst = leeg(body.body);
  if (!subject || !tekst) return c.html(await lijstHtml({ fout: "Een bericht heeft een onderwerp en een tekst nodig." }), 400);

  const { rows } = await query(LEDEN);
  const ontvangers = rows.filter((p) => !p.left_on && p.email);
  if (!ontvangers.length) return c.html(await lijstHtml({ fout: "Er is niemand om te mailen: geen actief lid met een e-mailadres.", mailing: { subject, body: tekst } }), 400);

  const eerste = groepsBericht(ontvangers[0], subject, tekst);
  /* Letterlijk het vel dat verstuurd wordt, alleen zonder de afmeld-plaatshouder die Postmark invult */
  const voorbeeld = vel(vulIn(tekst, ontvangers[0], letterlijk), AFMELD_BRIEF).replaceAll(AFMELD, "#");
  return c.html(beheerPagina("Voorbeeld — Promotieteam", `
<h1>Zo gaat hij eruit</h1>
<p class="mute">Naar ${ontvangers.length} ${ontvangers.length === 1 ? "persoon" : "mensen"}, ieder een eigen mail:
${esc(ontvangers.map((p) => p.name).join(", "))}. Achter "Meld je gerust af" zet Postmark bij het
versturen de persoonlijke afmeldlink van die ontvanger.</p>
<div class="kop mono">
  Van: ${esc(FROM)}<br>
  Aan: ${esc(eerste.to)}<br>
  Onderwerp: ${esc(subject)}
</div>
<div class="post">${voorbeeld}</div>
<p class="mute mono">Zo ziet hij eruit in een lichte mailclient; in een donkere draait het vel mee.
Wie geen opmaak leest krijgt dezelfde woorden zonder, met de afmeldlink voluit.</p>
<form class="rij" method="post" action="${PROMOTIE_PATH}/mailing/versturen" style="margin-top:16px">
  <input type="hidden" name="subject" value="${esc(subject)}">
  <textarea name="body" hidden>${esc(tekst)}</textarea>
  <div class="knoppen">
    <button>Versturen naar ${ontvangers.length}</button>
    <a class="stil" href="${PROMOTIE_PATH}">Nee, terug</a>
  </div>
</form>`));
});

promotie.post("/mailing/versturen", async (c) => {
  const body = await c.req.parseBody();
  const subject = leeg(body.subject), tekst = leeg(body.body);
  if (!subject || !tekst) return c.html(await lijstHtml({ fout: "Een bericht heeft een onderwerp en een tekst nodig." }), 400);

  const { rows } = await query(LEDEN);
  const ontvangers = rows.filter((p) => !p.left_on && p.email);
  let uitslag;
  try {
    uitslag = await sendBatch(ontvangers.map((p) => groepsBericht(p, subject, tekst)));
  } catch (err) {
    return c.html(await lijstHtml({ fout: `Er ging niets weg: ${err.message}`, mailing: { subject, body: tekst } }));
  }

  const gelukt = uitslag.filter((r) => r.ok);
  const mislukt = uitslag.filter((r) => !r.ok);
  await query("insert into mailings (subject, body, aantal, mislukt) values ($1, $2, $3, $4)",
    [subject, tekst, gelukt.length, mislukt.map((r) => `${r.to} (${r.fout})`).join("; ")]);

  return c.html(await lijstHtml({
    melding: mislukt.length ? "" : `Verstuurd naar ${gelukt.length} ${gelukt.length === 1 ? "persoon" : "mensen"}.`,
    fout: mislukt.length ? `Verstuurd naar ${gelukt.length}, mislukt bij ${mislukt.length}: ${mislukt.map((r) => `${r.to} — ${r.fout}`).join("; ")}` : "",
  }));
});

/*
 * Voor `npm run doctor`. Drie dingen kunnen hier na een uitrol stil mislopen: het wachtwoord
 * ontbreekt in de omgeving (dan staat de pagina dicht met 503 — vervelend, maar niet gevaarlijk),
 * het staat er wél terwijl de database niet bestaat (dan loopt de pagina op een 500), of de post
 * kan er niet uit doordat het Postmark-token ontbreekt. Het verschil bepaalt of de zelftest
 * hierop faalt of alleen waarschuwt.
 */
export async function promotieSelfTest() {
  const slot = Boolean(process.env.PROMOTIE_PASSWORD);
  try {
    const { rows } = await query(
      "select count(*) filter (where left_on is null) as actief, count(*) as totaal from promoters");
    const { actief, totaal } = rows[0];
    if (!slot) return { ok: false, hard: false, uitleg: `de lijst staat er (${totaal}), maar PROMOTIE_PASSWORD ontbreekt — /promotieteam antwoordt met 503` };
    if (!mailReady()) return { ok: false, hard: false, uitleg: `${actief} actieve leden, maar POSTMARK_SERVER_TOKEN ontbreekt — er kan geen post uit` };
    return { ok: true, uitleg: `${actief} actieve leden (${totaal} in totaal), op slot, post gaat uit via ${STREAM_GROEP}` };
  } catch (err) {
    return slot
      ? { ok: false, hard: true, uitleg: `PROMOTIE_PASSWORD staat er, maar de database is onbereikbaar: ${err.message} — draai npm run db:promotie` }
      : { ok: false, hard: false, uitleg: `niet in gebruik op deze installatie (geen wachtwoord, geen database)` };
  }
}

/* De zelftest draait buiten de server; daarna hoort de verbinding weer dicht */
export const promotiePoolEnd = () => pool.end();

export default promotie;