diff --git a/server/index.js b/server/index.js index 91d24dee..08741aca 100644 --- a/server/index.js +++ b/server/index.js @@ -40,6 +40,7 @@ import { hilfeRouter } from "./workspace-hilfe.js"; import { supportRouter } from "./workspace-support.js"; import { notizenRouter } from "./workspace-notizen.js"; import { reaktionRouter } from "./workspace-reaktion.js"; +import { spendenRouter } from "./workspace-spenden.js"; import { unterstuetzungRouter } from "./workspace-unterstuetzung.js"; import { reportRouter } from "./workspace-reports.js"; import { scoutRouter } from "./workspace-scouts.js"; @@ -334,6 +335,7 @@ app.use(supportRouter); notiz-tabellen.js. */ app.use(notizenRouter); app.use(reaktionRouter); +app.use(spendenRouter); /* Unterstützen (24.09.2026): die Wege, auf denen die Community etwas beitragen kann – lesen alle, ändern nur DogFather. */ app.use(unterstuetzungRouter); diff --git a/server/mess-reaktion.mjs b/server/mess-reaktion.mjs index 518bd754..515d9056 100644 --- a/server/mess-reaktion.mjs +++ b/server/mess-reaktion.mjs @@ -398,6 +398,7 @@ const regie = await host.seite.evaluate(() => { spur: t("spur-titel"), zeit: t("v-zeit"), reiter: document.querySelectorAll("#reiter .reiter__knopf").length, + tafeln: document.querySelectorAll(".pult-tafel").length, tafel_sichtbar: [...document.querySelectorAll(".pult-tafel")].filter((e) => !e.hidden).length, regler: document.querySelectorAll("#regler .regler").length, pegel: document.querySelectorAll("#regler .pegel").length, @@ -450,9 +451,13 @@ if (!/kbit\/s|Mbit\/s/.test(regie.last)) { console.log(` ACHTUNG: Der Upload wird nicht gemessen (${regie.last}).`); ausgang = 1; } -if (regie.reiter !== 5 || regie.tafel_sichtbar !== 1) { +/* GEZAEHLT, NICHT FESTGESCHRIEBEN. Vorher stand hier „genau 5" -- + und beim sechsten Register wurde die Messung rot, obwohl nichts + kaputt war. Was zaehlt, ist: zu jedem Reiter gehoert eine Tafel, + und genau eine ist offen. */ +if (regie.reiter < 5 || regie.reiter !== regie.tafeln || regie.tafel_sichtbar !== 1) { console.log(` ACHTUNG: Die Register stimmen nicht (${regie.reiter} Reiter, ` - + `${regie.tafel_sichtbar} offen).`); + + `${regie.tafeln} Tafeln, ${regie.tafel_sichtbar} offen).`); ausgang = 1; } if (regie.regler !== 4 || regie.pegel !== 4 || regie.knoepfe_sichtbar !== 3) { @@ -506,6 +511,120 @@ if (!nachChat.gesperrt || !nachChat.grund.includes("Team") || trotzdem.code !== await host.seite.click('#chat-stufen .stufe[data-modus="offen"]').catch(() => {}); await host.seite.waitForTimeout(900); +/* ---- Die Spenden ------------------------------------------------- + Der ganze Weg: eintragen -> Karte laeuft bei allen. Melden -> + wartet. Bestaetigen -> laeuft. Und eine unbestaetigte Meldung + darf NICHT im Bild stehen. */ +console.log(""); +console.log(" --- Spenden ---"); + +/* Damit es Betragsknoepfe gibt, braucht die Unterstuetzen-Seite eine + PayPal.me-Adresse. Sie wird ueber den echten Weg gesetzt -- eine + Zeile direkt in die Datenbank zu schreiben wuerde genau die + Pruefungen umgehen, um die es geht. */ +const wege = await roh("/workspace/api/unterstuetzung", { keks: k.admin }); +const ppWeg = (wege.j?.wege || wege.j?.liste || []).find((w) => w.schluessel === "paypal"); +if (ppWeg) { + await roh(`/workspace/api/unterstuetzung/${ppWeg.id}`, { + method: "PUT", keks: k.admin, + rumpf: { + ziel: "https://paypal.me/probehase", knopf: "Dogi was ausgeben", sichtbar: true, + }, + }); +} +const spLage = await roh("/workspace/api/spenden", { keks: k.gast }); +console.log(` Betragsknoepfe: ${(spLage.j?.knoepfe || []).map((b) => b.ziel.split("/").pop()).join(", ") || "(keine)"}`); +if ((spLage.j?.knoepfe || []).length !== 4) { + console.log(" ACHTUNG: Die Betragsknoepfe fehlen."); + ausgang = 1; +} + +/* 1. Von Hand eintragen -- die Karte muss bei der Zuschauerin laufen. */ +await zuschauer.seite.evaluate(() => { window.__karten = []; }); +await zuschauer.seite.evaluate(() => { + const t = document.getElementById("spendentafel"); + new MutationObserver((m) => { + for (const e of m) { + for (const n of e.addedNodes) { + if (n.querySelector) { + window.__karten.push({ + betrag: n.querySelector(".spendenkarte__betrag")?.textContent?.trim(), + wer: n.querySelector(".spendenkarte__wer")?.textContent?.trim(), + stufe: n.querySelector(".spendenkarte__stufe")?.textContent?.trim(), + }); + } + } + } + }).observe(t, { childList: true }); +}); + +await roh("/workspace/api/spenden", { + method: "POST", keks: k.admin, + rumpf: { betrag_cent: 2500, name: "Nachtfalter", gruss: "Fuer das Rudel!" }, +}); +await zuschauer.seite.waitForFunction( + () => document.querySelector('.spendenkarte[data-da="ja"]'), null, { timeout: 8000 }, +).catch(() => {}); +await zuschauer.seite.waitForTimeout(700); +await bild(zuschauer.seite, "10-spendenkarte"); + +/* 2. Eine Meldung ohne Bestaetigung darf NICHT laufen. */ +await roh("/workspace/api/spenden/melden", { + method: "POST", keks: k.gast, rumpf: { betrag_cent: 50000 }, +}); +await zuschauer.seite.waitForTimeout(1200); + +const nachMelden = await zuschauer.seite.evaluate(() => window.__karten); +console.log(` Karten bei der Zuschauerin: ${JSON.stringify(nachMelden)}`); +if (nachMelden.length !== 1 || !/25/.test(nachMelden[0]?.betrag || "")) { + console.log(" ACHTUNG: Die eingetragene Spende kommt nicht als Karte an."); + ausgang = 1; +} +if (nachMelden.some((k2) => /500/.test(k2.betrag || ""))) { + console.log(" ACHTUNG: Eine UNBESTAETIGTE Meldung stand im Bild."); + ausgang = 1; +} + +/* 3. Bestaetigen -- jetzt muss sie laufen. */ +const offen = await roh("/workspace/api/spenden", { keks: k.admin }); +const wartet = (offen.j?.offen || [])[0]; +console.log(` Im Pult wartet: ${wartet ? wartet.betrag_cent / 100 + " EUR von " + wartet.name : "(nichts)"}`); +if (!wartet) { + console.log(" ACHTUNG: Die Meldung kommt nicht im Pult an."); + ausgang = 1; +} else { + await roh(`/workspace/api/spenden/${wartet.id}/bestaetigen`, { + method: "POST", keks: k.admin, rumpf: { betrag_cent: 1000 }, + }); + /* AUF DAS MERKMAL WARTEN, NICHT AUF DIE UHR. Die erste Karte + laeuft elf Sekunden (die hoechste Stufe steht am laengsten); die + zweite wartet in der Schlange, damit sie nicht uebereinander + liegen. Eine feste Pause haette das als Fehler gemeldet. */ + await zuschauer.seite.waitForFunction( + () => (window.__karten || []).length >= 2, null, { timeout: 20000 }, + ).catch(() => {}); + const nachJa = await zuschauer.seite.evaluate(() => window.__karten); + console.log(` Nach dem Bestaetigen: ${JSON.stringify(nachJa.slice(-1))}`); + if (nachJa.length !== 2 || !/10/.test(nachJa[1]?.betrag || "")) { + console.log(" ACHTUNG: Die bestaetigte Spende laeuft nicht -- oder mit falschem Betrag."); + ausgang = 1; + } + /* DIE STUFE MUSS ZUM BETRAG PASSEN. 25 EUR ist die hoechste + („Rudel-Legende"), 10 EUR die mittlere („Starke Runde"). Ohne + diese Probe koennte jede Karte dieselbe Stufe tragen und es + faellt niemandem auf. */ + console.log(` Stufen: ${nachMelden[0]?.stufe} / ${nachJa[1]?.stufe}`); + /* Der Text, nicht das Bild: Grossbuchstaben macht das Stilblatt. */ + if (nachMelden[0]?.stufe !== "Rudel-Legende" || nachJa[1]?.stufe !== "Starke Runde") { + console.log(" ACHTUNG: Die Stufe passt nicht zum Betrag."); + ausgang = 1; + } +} + +await host.seite.click('#reiter .reiter__knopf[data-tafel="spenden"]').catch(() => {}); +await host.seite.waitForTimeout(700); +await bild(host.seite, "9-live-regie-spenden"); + await vermessen(host.seite, "Host am Rechner"); await bild(host.seite, "3-live-host"); diff --git a/server/pruef-reaktion.mjs b/server/pruef-reaktion.mjs index 412b37d1..09787f63 100644 --- a/server/pruef-reaktion.mjs +++ b/server/pruef-reaktion.mjs @@ -809,8 +809,14 @@ melde("=== 12. Die Regie ==="); .map((m) => (m[0].match(/data-tafel="([a-z]+)"/) || [])[1]) .filter(Boolean); const tafeln = [...html.matchAll(/class="pult-tafel" data-tafel="([a-z]+)"/g)].map((m) => m[1]); - ok(reiter.length === 5 && reiter.every((r) => tafeln.includes(r)), - `zu jedem der ${reiter.length} Register gibt es eine Tafel (${reiter.join(", ")})`); + /* GEZAEHLT, NICHT FESTGESCHRIEBEN. Hier stand „genau 5" -- und + beim sechsten Register wurde die Pruefung rot, obwohl nichts + kaputt war. Was wirklich zaehlt: zu jedem Reiter gehoert eine + Tafel, und keine Tafel steht ohne Reiter da. */ + ok(reiter.length >= 5 + && reiter.length === tafeln.length + && reiter.every((r) => tafeln.includes(r)), + `zu jedem der ${reiter.length} Register gibt es genau eine Tafel (${reiter.join(", ")})`); const steuert = [...html.matchAll(/aria-controls="([^"]+)"/g)].map((m) => m[1]); ok(steuert.length > 0 && steuert.every((n) => vorhanden.has(n)), `alle ${steuert.length} Verweise mit aria-controls zeigen auf etwas Vorhandenes`); diff --git a/server/pruef-spenden.mjs b/server/pruef-spenden.mjs new file mode 100644 index 00000000..27c57f0b --- /dev/null +++ b/server/pruef-spenden.mjs @@ -0,0 +1,361 @@ +/* ===================================================================== + DIE SPENDEN (28.09.2026) + + Geprueft wird der ganze Weg aus Filipes Ansage: + + Betragsknoepfe -> PayPal -> melden -> wartet -> + bestaetigen -> Karte im Bild + + --------------------------------------------------------------------- + WARUM DIESE PRUEFUNG UEBER DIE HAUSADRESSE SPRICHT + + Dieselbe Begruendung wie bei der Reaction: Die Community meldet + sich ueber den stillen Zugang an, und der ist auf einer + Pruefadresse absichtlich abgeschaltet. `fetch` kann die + Host-Kopfzeile nicht setzen, deshalb node:http. + + Aufrufen mit: node server/pruef-spenden.mjs + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { request } from "node:http"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const ordner = mkdtempSync(join(tmpdir(), "ws-spend-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +import { notbremse } from "./helfer-notbremse.mjs"; +const { eigenerPort } = await import("./helfer-port.mjs"); +const PORT = await eigenerPort(import.meta, "die Spenden-Pruefung"); + +process.env.PORT = `${PORT}`; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +notbremse(200_000, "pruef-spenden"); +await new Promise((r) => setTimeout(r, 900)); + +let fehler = 0, geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +const melde = (t) => console.log(t); +process.on("exit", () => { try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } }); + +const CREW = "crew.dogfather-universe.com"; +const AGENTUR = "workspace.dogfather-universe.com"; + +/* ---------- Zugaenge anlegen -------------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const nun = new Date().toISOString(); +const kennSchluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", kennSchluessel, nun); + +/* 200 Tage alt: Der Treff hat Stufen, und wer neu ist, darf weniger. + Fuer die Reaction spielt das keine Rolle -- aber ein Zugang von + heute waere ein Sonderfall, den niemand im Blick hat. */ +const ALT = new Date(Date.now() - 200 * 86400000).toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n," + + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, + createHmac("sha256", kennSchluessel).update(code).digest("hex"), ALT); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const ROLLEN = [["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"], + ["modi", "Miss"], ["gast", "Nachtfalter"], ["gast2", "Lenny"], ["creator", "Luna"]]; +const CODES = {}; const IDS = {}; +for (const [i, [rolle, name]] of ROLLEN.entries()) { + CODES[rolle] = `CODE-R${String(i).padStart(2, "0")}-000001`; + IDS[rolle] = anlegen(name, rolle === "gast2" ? "gast" : rolle, CODES[rolle]); +} +d.close(); + +/* ---------- Sprechen mit Hausadresse ------------------------------------ */ +function roh(host, pfad, { method = "GET", keks = "", rumpf = null, origin } = {}) { + return new Promise((fertig, schief) => { + const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf)); + const a = request({ + host: "127.0.0.1", port: PORT, path: pfad, method, + headers: { + Host: host, + Origin: origin === undefined ? `https://${host}` : origin, + ...(keks ? { Cookie: keks } : {}), + ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), + }, + }, (antwort) => { + const teile = []; + antwort.on("data", (s) => teile.push(s)); + antwort.on("end", () => { + const text = Buffer.concat(teile).toString("utf8"); + let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ } + fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers }); + }); + }); + a.on("error", schief); + if (daten) a.write(daten); + a.end(); + }); +} + +async function anmelden(rolle, host) { + const a = await roh(host, "/workspace/api/anmelden", { + method: "POST", rumpf: { rolle: rolle === "gast2" ? "gast" : rolle, + code: CODES[rolle], alter_ok: true }, + }); + const keks = [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); + return a.code === 200 && keks ? keks : null; +} + +const k = {}; +for (const [rolle] of ROLLEN) k[rolle] = await anmelden(rolle, CREW); + +/* ======================================================================= + 1. DAS RECHNEN MIT GELD + ======================================================================= */ +melde("=== 1. Rechnen mit Geld ==="); +{ + /* NIE MIT KOMMAZAHLEN. 0.1 + 0.2 ist in jeder Programmiersprache + nicht 0.3, und bei Geld faellt das irgendwann jemandem auf -- + meistens dem, der zahlt. */ + const { mitBetrag, stufeFuer } = await import("./workspace-spenden.js"); + + ok(mitBetrag("https://paypal.me/probe", 500) === "https://paypal.me/probe/5EUR", + `runde Betraege ohne Nachkommastellen (${mitBetrag("https://paypal.me/probe", 500)})`); + ok(mitBetrag("https://paypal.me/probe", 750) === "https://paypal.me/probe/7.50EUR", + `krumme mit zweien (${mitBetrag("https://paypal.me/probe", 750)})`); + ok(mitBetrag("https://paypal.me/probe/", 200) === "https://paypal.me/probe/2EUR", + "ein Schraegstrich am Ende macht keinen doppelten"); + + /* AN EINER FREMDEN ADRESSE WIRD NICHTS ANGEHAENGT. Ein Betrag an + einer Seite, die ihn nicht versteht, fuehrt zu einer Seite, auf + der etwas anderes steht als auf dem Knopf. */ + ok(mitBetrag("https://example.com/spenden", 500) === null, + "an einer fremden Adresse wird nichts angehaengt"); + ok(mitBetrag("", 500) === null, "und an gar keiner auch nicht"); + + /* DIE HOECHSTE STUFE, DIE NOCH PASST. */ + ok(stufeFuer(50) === null, "unter der kleinsten Stufe gibt es keine"); + ok(stufeFuer(100)?.name === "Danke", `1 EUR ergibt ${stufeFuer(100)?.name}`); + ok(stufeFuer(499)?.name === "Danke", "4,99 EUR bleibt darunter"); + ok(stufeFuer(500)?.name === "Starke Runde", `5 EUR ergibt ${stufeFuer(500)?.name}`); + ok(stufeFuer(5000)?.name === "Rudel-Legende", `50 EUR ergibt ${stufeFuer(5000)?.name}`); +} + +/* ======================================================================= + 2. DIE AGENTUR KENNT DIE SPENDEN NICHT + ======================================================================= */ +melde(""); +melde("=== 2. Getrennte Haeuser ==="); +{ + const kCreator = await anmelden("creator", AGENTUR); + ok(!!kCreator, "eine Creatorin meldet sich im Agenturhaus an"); + const a = await roh(AGENTUR, "/workspace/api/spenden", { keks: kCreator }); + ok(a.code === 404, `und sieht die Spenden nicht (${a.code})`); + + /* GEGENPROBE: Im eigenen Haus geht derselbe Weg. Sonst koennte die + 404 auch daran liegen, dass es den Weg gar nicht gibt. */ + const b = await roh(CREW, "/workspace/api/spenden", { keks: k.gast }); + ok(b.code === 200, `im Teamhaus schon (${b.code})`); +} + +/* ======================================================================= + 3. DIE BETRAGSKNOEPFE + ======================================================================= */ +melde(""); +melde("=== 3. Die Betragsknoepfe ==="); +{ + const ohne = await roh(CREW, "/workspace/api/spenden", { keks: k.gast }); + ok(Array.isArray(ohne.j?.knoepfe) && ohne.j.knoepfe.length === 0, + `ohne hinterlegte Adresse gibt es keine Knoepfe (${ohne.j?.knoepfe?.length})`); + ok(ohne.j?.paypal === null, "und auch keinen Weg"); + + /* Jetzt eine eintragen -- ueber den echten Weg, nicht in die + Datenbank. Genau diese Pruefungen sollen ja mitlaufen. */ + const wege = await roh(CREW, "/workspace/api/unterstuetzung", { keks: k.admin }); + const pp = (wege.j?.wege || wege.j?.liste || []).find((w) => w.schluessel === "paypal"); + ok(!!pp, "der PayPal-Weg steht in der Unterstuetzen-Liste"); + await roh(CREW, `/workspace/api/unterstuetzung/${pp.id}`, { + method: "PUT", keks: k.admin, + rumpf: { ziel: "https://paypal.me/probehase", knopf: "Dogi was ausgeben", sichtbar: true }, + }); + + const mit = await roh(CREW, "/workspace/api/spenden", { keks: k.gast }); + ok(mit.j?.knoepfe?.length === 4, `jetzt gibt es vier (${mit.j?.knoepfe?.length})`); + ok(mit.j?.knoepfe?.[0]?.ziel === "https://paypal.me/probehase/2EUR", + `mit dem Betrag in der Adresse (${mit.j?.knoepfe?.[0]?.ziel})`); + ok(mit.j?.paypal?.knopf === "Dogi was ausgeben", + "und die Aufschrift kommt von der Unterstuetzen-Seite"); + + /* WER NICHT DIE LEITUNG IST, SIEHT KEINE FREMDEN BETRAEGE. */ + ok(mit.j?.offen === null && mit.j?.letzte === null, + "ein Zuschauer bekommt die Liste fremder Spenden NICHT"); + const alsChef = await roh(CREW, "/workspace/api/spenden", { keks: k.admin }); + ok(Array.isArray(alsChef.j?.offen), "die Leitung schon"); +} + +/* ======================================================================= + 4. MELDEN — EINE BEHAUPTUNG, KEINE TATSACHE + ======================================================================= */ +melde(""); +melde("=== 4. Melden ==="); +{ + const mist = await roh(CREW, "/workspace/api/spenden/melden", { + method: "POST", keks: k.gast, rumpf: { betrag_cent: 0 }, + }); + ok(mist.code === 400 && mist.j?.fehler === "betrag_unklar", + `null Euro ist kein Betrag (${mist.code}/${mist.j?.fehler})`); + const zuViel = await roh(CREW, "/workspace/api/spenden/melden", { + method: "POST", keks: k.gast, rumpf: { betrag_cent: 99999999 }, + }); + ok(zuViel.code === 400, `und eine Million auch nicht (${zuViel.code})`); + + const a = await roh(CREW, "/workspace/api/spenden/melden", { + method: "POST", keks: k.gast, rumpf: { betrag_cent: 1500, gruss: "Danke fuer alles" }, + }); + ok(a.code === 201 && a.j?.wartet === true, + `die Meldung wird angenommen -- und sie WARTET (${a.code})`); + + /* EINE JE MINUTE. Nicht gegen Betrug -- die Leitung bestaetigt ja + -- sondern gegen den Doppelklick und gegen eine Liste, die man + nicht mehr durchsieht. */ + const zweite = await roh(CREW, "/workspace/api/spenden/melden", { + method: "POST", keks: k.gast, rumpf: { betrag_cent: 1500 }, + }); + ok(zweite.code === 429, `zweimal in derselben Minute nicht (${zweite.code})`); + + const beiDerLeitung = await roh(CREW, "/workspace/api/spenden", { keks: k.admin }); + const wartet = (beiDerLeitung.j?.offen || [])[0]; + ok(wartet?.betrag_cent === 1500 && wartet?.stand === "offen", + `sie steht im Pult und wartet (${wartet?.betrag_cent} Cent, ${wartet?.stand})`); + ok(wartet?.name === "Nachtfalter", `mit dem Namen der Person (${wartet?.name})`); + ok(wartet?.stufe?.name === "Starke Runde", + `und der Stufe, die zum Betrag passt (${wartet?.stufe?.name})`); + + const beiIhr = await roh(CREW, "/workspace/api/spenden", { keks: k.gast }); + ok(beiIhr.j?.offen === null, "sie selbst bekommt keine Liste"); +} + +/* ======================================================================= + 5. NUR DIE LEITUNG ENTSCHEIDET + ======================================================================= */ +melde(""); +melde("=== 5. Wer ueber Geld entscheidet ==="); +{ + const offen = await roh(CREW, "/workspace/api/spenden", { keks: k.admin }); + const id = offen.j.offen[0].id; + + for (const rolle of ["linke", "modi", "gast"]) { + const a = await roh(CREW, `/workspace/api/spenden/${id}/bestaetigen`, { + method: "POST", keks: k[rolle], + }); + ok(a.code === 403 && a.j?.fehler === "nur_leitung", + `${rolle}: bestaetigt nichts (${a.code}/${a.j?.fehler})`); + const b = await roh(CREW, "/workspace/api/spenden", { + method: "POST", keks: k[rolle], rumpf: { betrag_cent: 100, name: "X" }, + }); + ok(b.code === 403, `${rolle}: traegt auch nichts ein (${b.code})`); + } + + /* DER BETRAG DARF BEIM BESTAETIGEN BERICHTIGT WERDEN. Die Leitung + hat die PayPal-Meldung vor sich und weiss es besser als die + Behauptung. */ + const ja = await roh(CREW, `/workspace/api/spenden/${id}/bestaetigen`, { + method: "POST", keks: k.hand, rumpf: { betrag_cent: 1200 }, + }); + ok(ja.code === 200 && ja.j?.stand === "bestaetigt" && ja.j?.betrag_cent === 1200, + `die rechte Hand bestaetigt und berichtigt (${ja.j?.betrag_cent} Cent)`); + + /* ZWEIMAL BESTAETIGEN IST KEIN FEHLER -- aber auch kein Grund, die + Karte noch einmal zu zeigen. Zwei Leute tippen gleichzeitig. */ + const nochmal = await roh(CREW, `/workspace/api/spenden/${id}/bestaetigen`, { + method: "POST", keks: k.admin, + }); + ok(nochmal.code === 200 && nochmal.j?.betrag_cent === 1200, + "ein zweites Mal aendert nichts und ist kein Fehler"); + + const danach = await roh(CREW, "/workspace/api/spenden", { keks: k.admin }); + ok((danach.j?.offen || []).length === 0, "danach wartet nichts mehr"); + ok((danach.j?.letzte || []).some((z) => z.id === id), "und sie steht bei den gezeigten"); +} + +/* ======================================================================= + 6. VON HAND EINTRAGEN — DER WEG, DER IMMER GEHT + ======================================================================= */ +melde(""); +melde("=== 6. Von Hand eintragen ==="); +{ + const ohneName = await roh(CREW, "/workspace/api/spenden", { + method: "POST", keks: k.admin, rumpf: { betrag_cent: 500 }, + }); + ok(ohneName.code === 400 && ohneName.j?.fehler === "name_fehlt", + `ohne Namen keine Karte -- er steht ja darauf (${ohneName.code})`); + + const a = await roh(CREW, "/workspace/api/spenden", { + method: "POST", keks: k.admin, + rumpf: { betrag_cent: 5000, name: "Miss", gruss: "Weiter so!" }, + }); + ok(a.code === 201 && a.j?.stand === "bestaetigt", + `eingetragen gilt sofort (${a.code}/${a.j?.stand})`); + ok(a.j?.stufe?.name === "Rudel-Legende" && a.j?.stufe?.dauer_ms === 11000, + `mit der hoechsten Stufe und ihrer Dauer (${a.j?.stufe?.name}, ${a.j?.stufe?.dauer_ms} ms)`); + + const wieder = await roh(CREW, `/workspace/api/spenden/${a.j.id}/nochmal`, { + method: "POST", keks: k.admin, + }); + ok(wieder.code === 200, `und laesst sich noch einmal zeigen (${wieder.code})`); + const ablehnen = await roh(CREW, `/workspace/api/spenden/${a.j.id}/ablehnen`, { + method: "POST", keks: k.admin, + }); + ok(ablehnen.code === 409 && ablehnen.j?.fehler === "nicht_offen", + `was schon lief, laesst sich nicht mehr ablehnen (${ablehnen.code})`); +} + +/* ======================================================================= + 7. DIESELBE ZAHLUNG NUR EINMAL + ======================================================================= */ +melde(""); +melde("=== 7. Keine Spende zweimal ==="); +{ + /* Ein Webhook wird bei einer Stoerung wiederholt. Ohne diese + Sperre stuende dieselbe Spende zweimal im Bild -- und zwar + ausgerechnet dann, wenn das Netz hakt. */ + const { DatabaseSync: DB2 } = await import("node:sqlite"); + const d2 = new DB2(process.env.WORKSPACE_DB); + const nun = new Date().toISOString(); + const rein = (nummer) => d2.prepare( + "INSERT INTO spenden (betrag_cent, waehrung, name, herkunft, stand, fremd_id, erstellt)" + + " VALUES (?,?,?,?,?,?,?)").run(300, "EUR", "Webhook", "paypal", "bestaetigt", nummer, nun); + + rein("PAY-1"); + let zweimal = false; + try { rein("PAY-1"); zweimal = true; } catch { /* genau so soll es sein */ } + ok(!zweimal, "dieselbe Zahlungsnummer laesst sich kein zweites Mal eintragen"); + + /* GEGENPROBE: Ohne Nummer duerfen beliebig viele nebeneinander + stehen -- sonst koennte man nur eine Spende von Hand eintragen. */ + let ohneNummer = 0; + for (let i = 0; i < 3; i++) { + d2.prepare("INSERT INTO spenden (betrag_cent, waehrung, name, herkunft, stand, erstellt)" + + " VALUES (?,?,?,?,?,?)").run(100, "EUR", "Ohne", "hand", "bestaetigt", nun); + ohneNummer++; + } + ok(ohneNummer === 3, "Gegenprobe: ohne Nummer stehen beliebig viele nebeneinander"); + d2.close(); +} + +melde(""); +melde(`${geprueft} Pruefungen, ${fehler} Fehler`); +melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); +process.exit(fehler === 0 ? 0 : 1); diff --git a/server/spenden-tabellen.js b/server/spenden-tabellen.js new file mode 100644 index 00000000..ac945714 --- /dev/null +++ b/server/spenden-tabellen.js @@ -0,0 +1,162 @@ +/* ===================================================================== + DIE SPENDEN — die Tabellen (28.09.2026) + + Filipe: „ich will dass du das mit dem paypal auch perfektionierst. + ich will dass das richtig perfekt gemacht wird so dass die leute so + einfach wie möglich eine spende aufs paypal machen können. und wenn + jemand spendet soll auch der betrag erscheinen mit einem bild." + + --------------------------------------------------------------------- + WAS GEHT UND WAS NICHT — nachgesehen, nicht angenommen + + Hinterlegt ist ein PayPal.me-Link auf ein PRIVATES Konto. Damit + gilt: + + ES GEHT: `paypal.me//5EUR` öffnet PayPal mit schon + eingetragenem Betrag. Ein Tipp, fertig. Das ist der einfachste + Weg, den es für einen Zuschauer überhaupt gibt, und er braucht + nichts weiter als den Link, der ohnehin schon da ist. + + ES GEHT NICHT VON SELBST: PayPal meldet eine Zahlung nur, wenn + ein Webhook oder IPN eingerichtet ist — und beides setzt + Zugangsdaten voraus, die nur Filipe selbst anlegen kann. Ob ein + privates Konto das ueberhaupt kann, sagt PayPals eigene Doku + nicht eindeutig. + + DESHALB DREI HERKUENFTE UND NICHT EINE: + + „hand" — Filipe sieht die PayPal-Meldung auf dem Handy und + tippt den Betrag ins Pult. Geht immer, braucht + nichts, ist in drei Sekunden getan. + „gemeldet" — Der Zuschauer sagt nach dem Spenden selbst + Bescheid. Landet als OFFEN und wird erst gezeigt, + wenn jemand von der Leitung es bestaetigt. + „paypal" — Kommt automatisch, sobald ein Webhook eingerichtet + ist. Bis dahin steht dieser Weg einfach leer da. + + WARUM „gemeldet" NICHT SOFORT ERSCHEINT: Sonst tippt irgendwer + „500 Euro" und steht damit gross im Bild. Eine Spende ist eine + Aussage ueber Geld; die gehoert bestaetigt, bevor sie oeffentlich + wird. Der Weg dahin ist EIN Tipp im Pult — das ist billig genug, + dass niemand in Versuchung kommt, ihn abzukuerzen. + + --------------------------------------------------------------------- + DER BETRAG STEHT IN CENT + + Nie als Kommazahl. 0.1 + 0.2 ist in jeder Programmiersprache nicht + 0.3, und bei Geld faellt das irgendwann jemandem auf — meistens + dem, der zahlt. Gerechnet wird in ganzen Cent, formatiert wird + erst beim Anzeigen. + ===================================================================== */ + +/** So lange bleibt eine Spende im Verlauf stehen. */ +export const SPENDEN_TAGE = 400; + +/** Hoechstbetrag, den jemand von Hand eintragen kann (in Cent). + * + * Nicht gegen Betrug — wer eintraegt, gehoert zur Leitung. Gegen + * den Zahlendreher: Eine Null zu viel steht sonst als „5.000 EUR" + * im Bild, und das ist der Sorte Fehler, die man nicht mehr + * einfangen kann, wenn hundert Leute zusehen. */ +export const BETRAG_MAX_CENT = 500000; + +/** Was auf der Karte stehen darf. */ +export const NAME_MAX = 40; +export const GRUSS_MAX = 200; + +/** Die Stufen, die es ab Werk gibt — „paar fertige" aus Filipes + * Ansage. Sie stehen hier und nicht in der Datenbank, damit ein + * frisches Haus sofort etwas anzuzeigen hat; geaendert werden sie + * danach in der Datenbank. + * + * DIE GRENZEN SIND NICHT GERATEN, sondern an dem ausgerichtet, was + * Leute im Netz tatsaechlich geben: der Kaffee, die Runde, das + * richtig grosse Danke. Drei Stufen, nicht sieben — bei sieben + * merkt sich niemand mehr, was welche bedeutet. */ +export const STUFEN_START = [ + { ab_cent: 100, name: "Danke", vorlage: "herz", farbe: "#8ec9ff", dauer_ms: 6000 }, + { ab_cent: 500, name: "Starke Runde", vorlage: "welle", farbe: "#a98bff", dauer_ms: 8000 }, + { ab_cent: 2000, name: "Rudel-Legende", vorlage: "krone", farbe: "#e0a13a", dauer_ms: 11000 }, +]; + +/** Die Betraege, die als Knopf angeboten werden (in Cent). + * + * VIER UND EIN FREIES FELD. Wer eine lange Liste sieht, rechnet; + * wer vier Knoepfe sieht, tippt. Die Reihe endet bewusst nicht bei + * einer runden Zahl wie 50 — das waere eine Erwartung, keine + * Auswahl. */ +export const BETRAG_KNOEPFE = [200, 500, 1000, 2500]; + +export function spendenTabellen(d) { + d.exec(` + /* Eine Zeile je Spende. Auch die abgelehnten bleiben stehen: + Wer zweimal dasselbe meldet, soll nicht zweimal gefragt + werden, und wer sich einen Spass erlaubt, faellt beim dritten + Mal auf. */ + CREATE TABLE IF NOT EXISTS spenden ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + betrag_cent INTEGER NOT NULL CHECK (betrag_cent > 0), + waehrung TEXT NOT NULL DEFAULT 'EUR', + /* Wie es im Bild stehen soll. Darf vom Kontonamen abweichen -- + viele wollen mit ihrem Rudelnamen dastehen, nicht mit dem, + der auf der Karte steht. */ + name TEXT NOT NULL DEFAULT '', + gruss TEXT NOT NULL DEFAULT '', + person_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + herkunft TEXT NOT NULL + CHECK (herkunft IN ('hand','gemeldet','paypal')), + stand TEXT NOT NULL DEFAULT 'offen' + CHECK (stand IN ('offen','bestaetigt','abgelehnt')), + /* Die Nummer der Zahlung bei PayPal. Nur gesetzt, wenn sie von + dort kam -- und dann EINMALIG (siehe Index unten): Ein + Webhook wird bei einer Stoerung wiederholt, und ohne diese + Sperre stuende dieselbe Spende zweimal im Bild. */ + fremd_id TEXT, + erstellt TEXT NOT NULL, + bestaetigt_am TEXT, + bestaetigt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + /* Wann die Karte im Bild lief. Leer heisst: steht noch aus. + Ohne dieses Feld liefe nach jedem Neuladen alles noch + einmal -- und zwar alles auf einmal. */ + gezeigt_am TEXT + ); + + /* DIESELBE ZAHLUNG NUR EINMAL. die Bedingung „fremd_id IS NOT NULL“, + damit die vielen Zeilen ohne Nummer (von Hand, gemeldet) + einander nicht im Weg stehen. */ + CREATE UNIQUE INDEX IF NOT EXISTS idx_spenden_fremd + ON spenden (fremd_id) WHERE fremd_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_spenden_stand + ON spenden (stand, id); + + /* Was bei welchem Betrag passiert. Eine Zeile je Stufe. */ + CREATE TABLE IF NOT EXISTS spenden_stufen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ab_cent INTEGER NOT NULL UNIQUE CHECK (ab_cent > 0), + name TEXT NOT NULL, + /* Entweder eine mitgelieferte Vorlage ("herz", "welle", + "krone") oder der Pfad eines hochgeladenen Bildes. Ein Feld + und nicht zwei: „welches von beidem gilt" waere sonst eine + Frage, die jede Stelle einzeln beantworten muesste. */ + vorlage TEXT NOT NULL DEFAULT 'herz', + bild TEXT NOT NULL DEFAULT '', + farbe TEXT NOT NULL DEFAULT '#8ec9ff', + dauer_ms INTEGER NOT NULL DEFAULT 7000 + CHECK (dauer_ms BETWEEN 2000 AND 30000), + geaendert TEXT NOT NULL DEFAULT '' + ); + `); + + /* DIE DREI STUFEN ANLEGEN, WENN ES NOCH KEINE GIBT. + + `INSERT OR IGNORE` je Zeile und nicht „erst zaehlen, dann + schreiben": Zwei Anfragen beim ersten Start wuerden sonst beide + „ist leer" lesen und beide schreiben. */ + const nun = new Date().toISOString(); + for (const s of STUFEN_START) { + d.prepare(`INSERT OR IGNORE INTO spenden_stufen + (ab_cent, name, vorlage, farbe, dauer_ms, geaendert) + VALUES (?,?,?,?,?,?)`) + .run(s.ab_cent, s.name, s.vorlage, s.farbe, s.dauer_ms, nun); + } +} diff --git a/server/workspace-aufbewahrung.js b/server/workspace-aufbewahrung.js index 7f5f8438..bda60ae7 100644 --- a/server/workspace-aufbewahrung.js +++ b/server/workspace-aufbewahrung.js @@ -47,7 +47,9 @@ import { AUFBEWAHRUNG_TAGE as SUPPORT_TAGE } from "./support-tabellen.js"; import { supportAufraeumen } from "./workspace-support.js"; import { notizenAufraeumen } from "./workspace-notizen.js"; import { reaktionAufraeumen } from "./workspace-reaktion.js"; +import { spendenAufraeumen } from "./workspace-spenden.js"; import { VERLAUF_TAGE } from "./reaktion-tabellen.js"; +import { SPENDEN_TAGE } from "./spenden-tabellen.js"; import { PAPIERKORB_TAGE as NOTIZ_TAGE } from "./notiz-tabellen.js"; import { ZAEHLUNG_TAGE } from "./unterstuetzung-tabellen.js"; import { unterstuetzungAufraeumen } from "./workspace-unterstuetzung.js"; @@ -342,6 +344,22 @@ export const FRISTEN = [ vorTagen(VERLAUF_TAGE)), raeumen: () => reaktionAufraeumen(), }, + { + schluessel: "spenden", + was: "Spenden und ihre Karten", + art: "raeumen", + frist: `${SPENDEN_TAGE} Tage`, + zweck: "Nachsehen können, wer wann unterstützt hat – und eine Karte " + + "noch einmal zeigen, falls sie im falschen Moment lief.", + grundlage: "Art. 5 Abs. 1 lit. e DSGVO – Speicherbegrenzung", + wirkung: "Die Zeile verschwindet: Betrag, Name auf der Karte, Gruß, " + + "woher sie kam und wer sie bestätigt hat. Die Zahlung selbst " + + "liegt bei PayPal, nicht hier – dort gelten deren Fristen.", + offen: (d) => zaehle(d, + "SELECT COUNT(*) AS n FROM spenden WHERE erstellt < ?", + vorTagen(SPENDEN_TAGE)), + raeumen: () => spendenAufraeumen(), + }, { schluessel: "unterstuetzung_striche", was: "Tagesstriche der Unterstützen-Seite", diff --git a/server/workspace-spenden.js b/server/workspace-spenden.js new file mode 100644 index 00000000..e3612d52 --- /dev/null +++ b/server/workspace-spenden.js @@ -0,0 +1,344 @@ +/* ===================================================================== + DIE SPENDEN — die Wege (28.09.2026) + + Drei Herkuenfte, eine Tabelle, ein Weg ins Bild. Die Begruendung + fuer die drei steht in spenden-tabellen.js. + + --------------------------------------------------------------------- + DER EINFACHSTE WEG FUER EINEN ZUSCHAUER + + Vier Knoepfe mit Betraegen. Ein Tipp oeffnet PayPal mit schon + eingetragener Summe (`paypal.me//5EUR`). Danach kommt er + zurueck, und die Seite fragt EINMAL: „Hast du gespendet?" Sagt er + ja, steht die Spende als OFFEN im Pult und wartet auf einen Tipp + der Leitung. + + DIE ADRESSE WIRD NICHT ZWEIMAL GEPFLEGT. Sie steht auf der + Unterstuetzen-Seite, dort und nur dort. Was hier passiert, ist + Lesen und Anhaengen des Betrags -- und wenn dort nichts steht oder + der Weg auf unsichtbar gestellt ist, gibt es hier auch keine + Knoepfe. Eine geratene Kontoadresse waere die gefaehrlichste aller + Abkuerzungen. + + --------------------------------------------------------------------- + WARUM DER BETRAG NICHT AN DIE ADRESSE GEBUNDEN IST + + `paypal.me/x/5EUR` traegt den Betrag VOR -- aendern kann ihn jeder + auf PayPals Seite, und das ist auch richtig so. Was hier steht, + ist deshalb nie die Wahrheit ueber das Geld, sondern nur ein + Vorschlag. Die Wahrheit kommt entweder von PayPal selbst (Webhook) + oder von einem Menschen, der die Meldung auf seinem Handy gesehen + hat. Alles andere waere geraten. + ===================================================================== */ +import express from "express"; +import { db, sitzungLesen, protokolliere, echteIp } from "./workspace.js"; +import { stromAn } from "./workspace-chat.js"; +import { + BETRAG_MAX_CENT, NAME_MAX, GRUSS_MAX, BETRAG_KNOEPFE, SPENDEN_TAGE, +} from "./spenden-tabellen.js"; + +export const spendenRouter = express.Router(); + +/** Wer eine Spende bestaetigen, eintragen oder ablehnen darf. + * + * Dieselben zwei, die die Sendung fuehren. Geld ist keine + * Moderationsaufgabe: Einen Beitrag wegzunehmen ist umkehrbar, + * einen Betrag im Bild gross zu machen nicht. */ +const GELD_ROLLEN = new Set(["admin", "hand"]); +export const SPENDEN_ROLLEN = GELD_ROLLEN; + +const jetzt = () => new Date().toISOString(); + +function angemeldet(req, res, next) { + const p = sitzungLesen(req); + if (!p) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = p; + next(); +} + +/** Spenden gehoeren zum Team-Dogi-Haus. + * + * Dieselbe Form wie bei der Reaction und beim Notizblock: gefragt + * wird, ob es das ANDERE Haus ist -- nicht, ob es Crew ist. Auf + * einer Pruefadresse hat niemand ein Haus, und „ist nicht crew" + * haette die Tuer dort zugemacht. */ +function nurTeamDogi(req, res, next) { + if (req.person?.haus === "agentur") return res.status(404).end(); + next(); +} + +function nurGeld(req, res, next) { + if (!GELD_ROLLEN.has(req.person.rolle)) { + return res.status(403).json({ fehler: "nur_leitung" }); + } + next(); +} + +/** Schuetzt gegen fremde Seiten, die im Namen eines Angemeldeten + * schreiben. Dieselbe Pruefung wie im Anruf- und Reaktionsteil. */ +function gleicheHerkunft(req, res, next) { + if (req.method === "GET") return next(); + const woher = req.get("Origin") || ""; + if (!woher) return next(); + try { + if (new URL(woher).host !== req.get("Host")) { + return res.status(403).json({ fehler: "fremde_herkunft" }); + } + } catch { return res.status(403).json({ fehler: "fremde_herkunft" }); } + next(); +} + +/* ---------- Wer erfaehrt davon? --------------------------------------- + Alle, die gerade in der Reaction sitzen, plus der Host. Steht + gerade keine Sendung, gibt es niemanden -- dann wird die Spende + eingetragen und beim naechsten Mal gezeigt. Eine Karte, die ins + Leere laeuft, waere schlimmer als eine, die wartet. */ +function empfaenger() { + try { + const ids = new Set( + db().prepare("SELECT person_id FROM reaktion_dabei").all().map((z) => z.person_id)); + const s = db().prepare("SELECT host_id FROM reaktion WHERE id = 1").get(); + if (s?.host_id) ids.add(s.host_id); + return [...ids]; + } catch { return []; } +} + +/** Die PayPal-Adresse, wie sie auf der Unterstuetzen-Seite steht. */ +function paypalAdresse() { + try { + const w = db().prepare(`SELECT ziel, knopf FROM unterstuetzung_wege + WHERE schluessel = 'paypal' AND sichtbar = 1`).get(); + return w?.ziel ? { ziel: w.ziel, knopf: w.knopf || "" } : null; + } catch { return null; } +} + +/** Haengt einen Betrag an eine PayPal.me-Adresse. + * + * NUR BEI paypal.me. Bei jeder anderen Adresse wird nichts + * angehaengt und es gibt auch keine Betragsknoepfe -- ein Betrag an + * einer Adresse, die ihn nicht versteht, fuehrt zu einer Seite, auf + * der etwas anderes steht als auf dem Knopf. */ +export function mitBetrag(ziel, cent, waehrung = "EUR") { + if (!ziel || !/^https:\/\/(www\.)?paypal\.me\//i.test(ziel)) return null; + const sauber = ziel.replace(/\/+$/, ""); + const zahl = (cent % 100 === 0) ? String(cent / 100) + : (cent / 100).toFixed(2); + return `${sauber}/${zahl}${waehrung}`; +} + +/** Welche Stufe gilt fuer diesen Betrag? + * + * DIE HOECHSTE, DIE NOCH PASST. Ohne diese Regel muesste jede Stufe + * eine Ober- UND eine Untergrenze haben, und zwei Grenzen je Stufe + * sind die doppelte Gelegenheit, eine Luecke zu lassen -- durch die + * faellt dann ausgerechnet der grosse Betrag. */ +export function stufeFuer(cent) { + try { + return db().prepare(`SELECT * FROM spenden_stufen + WHERE ab_cent <= ? ORDER BY ab_cent DESC LIMIT 1`).get(cent) || null; + } catch { return null; } +} + +function alsKarte(z) { + const st = stufeFuer(z.betrag_cent); + return { + id: z.id, + betrag_cent: z.betrag_cent, + waehrung: z.waehrung, + name: z.name, + gruss: z.gruss, + herkunft: z.herkunft, + stand: z.stand, + erstellt: z.erstellt, + stufe: st ? { + name: st.name, vorlage: st.vorlage, bild: st.bild, + farbe: st.farbe, dauer_ms: st.dauer_ms, + } : null, + }; +} + +/** Eine bestaetigte Spende ins Bild schicken. */ +function zeigen(z) { + db().prepare("UPDATE spenden SET gezeigt_am = ? WHERE id = ?").run(jetzt(), z.id); + stromAn(empfaenger(), "spende", { was: "zeigen", karte: alsKarte(z) }); +} + +/* ====================================================================== + DIE WEGE + ====================================================================== */ +spendenRouter.use("/workspace/api/spenden", angemeldet, nurTeamDogi, gleicheHerkunft); + +/** Was die Seite zum Anzeigen braucht. */ +spendenRouter.get("/workspace/api/spenden", (req, res) => { + try { + const pp = paypalAdresse(); + const darf = GELD_ROLLEN.has(req.person.rolle); + const knoepfe = pp + ? BETRAG_KNOEPFE.map((c) => ({ cent: c, ziel: mitBetrag(pp.ziel, c) })) + .filter((k) => k.ziel) + : []; + res.json({ + paypal: pp ? { ziel: pp.ziel, knopf: pp.knopf } : null, + knoepfe, + stufen: db().prepare("SELECT ab_cent, name, vorlage, bild, farbe, dauer_ms" + + " FROM spenden_stufen ORDER BY ab_cent").all(), + betrag_max_cent: BETRAG_MAX_CENT, + ich: { darf }, + /* Die offenen Meldungen sieht nur, wer sie bestaetigen kann. + Fuer alle anderen waere es eine Liste fremder Geldbetraege. */ + offen: darf + ? db().prepare("SELECT * FROM spenden WHERE stand = 'offen' ORDER BY id") + .all().map(alsKarte) + : null, + letzte: darf + ? db().prepare("SELECT * FROM spenden WHERE stand = 'bestaetigt'" + + " ORDER BY id DESC LIMIT 20").all().map(alsKarte) + : null, + }); + } catch (fehler) { + console.error("[spenden] Lage:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** „Ich habe gespendet." — landet als OFFEN. + * + * BEWUSST OHNE PRUEFUNG DES BETRAGS GEGEN PAYPAL. Wir koennen ihn + * nicht kennen (siehe Kopf). Was hier entsteht, ist eine BEHAUPTUNG, + * und sie wird auch so behandelt: Sie steht im Pult und wartet. */ +spendenRouter.post("/workspace/api/spenden/melden", express.json({ limit: "4kb" }), + (req, res) => { + try { + const cent = Math.round(Number(req.body?.betrag_cent) || 0); + if (!Number.isFinite(cent) || cent <= 0 || cent > BETRAG_MAX_CENT) { + return res.status(400).json({ fehler: "betrag_unklar" }); + } + /* EINE MELDUNG JE MINUTE UND PERSON. Nicht gegen Betrug -- die + Leitung bestaetigt ja -- sondern gegen den Doppelklick und + gegen eine Liste, die man nicht mehr durchsieht. */ + const kurz = new Date(Date.now() - 60_000).toISOString(); + const schon = db().prepare(`SELECT COUNT(*) AS n FROM spenden + WHERE person_id = ? AND erstellt > ?`).get(req.person.id, kurz); + if ((schon?.n || 0) >= 1) return res.status(429).json({ fehler: "zu_schnell" }); + + const name = String(req.body?.name ?? req.person.name).trim().slice(0, NAME_MAX); + const gruss = String(req.body?.gruss ?? "").trim().slice(0, GRUSS_MAX); + const { lastInsertRowid } = db().prepare(`INSERT INTO spenden + (betrag_cent, waehrung, name, gruss, person_id, herkunft, stand, erstellt) + VALUES (?,?,?,?,?,'gemeldet','offen',?)`) + .run(cent, "EUR", name, gruss, req.person.id, jetzt()); + + const z = db().prepare("SELECT * FROM spenden WHERE id = ?").get(Number(lastInsertRowid)); + /* Die Leitung erfaehrt es sofort -- sonst liegt die Meldung da, + und der Zuschauer wartet auf eine Karte, die niemand sieht. */ + stromAn( + db().prepare("SELECT id FROM personen WHERE rolle IN ('admin','hand') AND aktiv = 1") + .all().map((p) => p.id), + "spende", { was: "offen", karte: alsKarte(z) }, + ); + res.status(201).json({ ok: true, wartet: true }); + } catch (fehler) { + console.error("[spenden] Melden:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Die Leitung traegt eine Spende ein — sie gilt sofort. */ +spendenRouter.post("/workspace/api/spenden", nurGeld, express.json({ limit: "4kb" }), + (req, res) => { + try { + const cent = Math.round(Number(req.body?.betrag_cent) || 0); + if (!Number.isFinite(cent) || cent <= 0 || cent > BETRAG_MAX_CENT) { + return res.status(400).json({ fehler: "betrag_unklar" }); + } + const name = String(req.body?.name ?? "").trim().slice(0, NAME_MAX); + if (!name) return res.status(400).json({ fehler: "name_fehlt" }); + const gruss = String(req.body?.gruss ?? "").trim().slice(0, GRUSS_MAX); + + const { lastInsertRowid } = db().prepare(`INSERT INTO spenden + (betrag_cent, waehrung, name, gruss, herkunft, stand, erstellt, + bestaetigt_am, bestaetigt_von) + VALUES (?,?,?,?,'hand','bestaetigt',?,?,?)`) + .run(cent, "EUR", name, gruss, jetzt(), jetzt(), req.person.id); + + const z = db().prepare("SELECT * FROM spenden WHERE id = ?").get(Number(lastInsertRowid)); + protokolliere(req.person.id, "spende_hand", `${cent} Cent von ${name}`, echteIp(req)); + zeigen(z); + res.status(201).json(alsKarte(z)); + } catch (fehler) { + console.error("[spenden] Eintragen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Eine gemeldete Spende bestaetigen — dann geht sie ins Bild. */ +spendenRouter.post("/workspace/api/spenden/:id(\\d+)/bestaetigen", nurGeld, + express.json({ limit: "4kb" }), (req, res) => { + try { + const z = db().prepare("SELECT * FROM spenden WHERE id = ?").get(Number(req.params.id)); + if (!z) return res.status(404).json({ fehler: "nicht_gefunden" }); + /* SCHON BESTAETIGT IST KEIN FEHLER, ABER AUCH KEIN GRUND, ES + NOCH EINMAL ZU ZEIGEN. Zwei Leute tippen gleichzeitig, und + die Karte liefe zweimal. */ + if (z.stand === "bestaetigt") return res.json(alsKarte(z)); + + /* Beim Bestaetigen darf der Betrag berichtigt werden: Die + Leitung hat die PayPal-Meldung vor sich und weiss es besser + als die Behauptung. */ + const cent = req.body?.betrag_cent === undefined + ? z.betrag_cent : Math.round(Number(req.body.betrag_cent) || 0); + if (!Number.isFinite(cent) || cent <= 0 || cent > BETRAG_MAX_CENT) { + return res.status(400).json({ fehler: "betrag_unklar" }); + } + + db().prepare(`UPDATE spenden SET stand = 'bestaetigt', betrag_cent = ?, + bestaetigt_am = ?, bestaetigt_von = ? WHERE id = ?`) + .run(cent, jetzt(), req.person.id, z.id); + const neu = db().prepare("SELECT * FROM spenden WHERE id = ?").get(z.id); + protokolliere(req.person.id, "spende_bestaetigt", `#${z.id}, ${cent} Cent`, echteIp(req)); + zeigen(neu); + res.json(alsKarte(neu)); + } catch (fehler) { + console.error("[spenden] Bestaetigen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +spendenRouter.post("/workspace/api/spenden/:id(\\d+)/ablehnen", nurGeld, (req, res) => { + try { + const n = db().prepare(`UPDATE spenden SET stand = 'abgelehnt', + bestaetigt_am = ?, bestaetigt_von = ? WHERE id = ? AND stand = 'offen'`) + .run(jetzt(), req.person.id, Number(req.params.id)); + if (!n.changes) return res.status(409).json({ fehler: "nicht_offen" }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[spenden] Ablehnen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Dieselbe Karte noch einmal zeigen — fuer den Fall, dass sie + * jemand verpasst hat oder die Technik im falschen Moment hakte. */ +spendenRouter.post("/workspace/api/spenden/:id(\\d+)/nochmal", nurGeld, (req, res) => { + try { + const z = db().prepare("SELECT * FROM spenden WHERE id = ? AND stand = 'bestaetigt'") + .get(Number(req.params.id)); + if (!z) return res.status(404).json({ fehler: "nicht_gefunden" }); + zeigen(z); + res.json({ ok: true }); + } catch (fehler) { + console.error("[spenden] Nochmal:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Aufraeumen: alte Spenden fallen aus dem Verlauf. */ +export function spendenAufraeumen() { + try { + const grenze = new Date(Date.now() - SPENDEN_TAGE * 86400_000).toISOString(); + const a = db().prepare("DELETE FROM spenden WHERE erstellt < ?").run(grenze); + if (a.changes) console.log(`[spenden] Aufgeraeumt: ${a.changes} Zeilen.`); + } catch (fehler) { + console.error("[spenden] Aufraeumen:", fehler?.message); + } +} diff --git a/server/workspace.js b/server/workspace.js index 89057062..349758a0 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -36,6 +36,7 @@ import { hilfeTabellen } from "./hilfe-tabellen.js"; import { supportTabellen } from "./support-tabellen.js"; import { notizTabellen } from "./notiz-tabellen.js"; import { reaktionTabellen } from "./reaktion-tabellen.js"; +import { spendenTabellen } from "./spenden-tabellen.js"; import { unterstuetzungTabellen } from "./unterstuetzung-tabellen.js"; import { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse, istPruefAdresse, AUSSEN_ROLLEN, TEAM_DOGI_ROLLEN, CREW_ADRESSE, WORKSPACE_ADRESSE, AGENTUR_ROLLEN, BEIDE_HAEUSER_ROLLEN, @@ -6066,6 +6067,7 @@ export function db() { kommt deshalb hierher, nicht in den Bauplan darueber. */ notizTabellen(d); reaktionTabellen(d); + spendenTabellen(d); /* Die Wege zum Unterstützen (24.09.2026). Sie hängen an personen (geaendert_von) und kommen deshalb hierher, nicht in den Bauplan darüber. */ diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 0bfb27cc..bf7e4886 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +