Spenden: Stufen gestaltbar, Bild hochladen, Größe je Stufe

Filipes Wunsch: „mach paar fertige und so dass ich auch hochladen
kann. auch so dass ich das anders gestalten kann oder die größe
verändern kann. ... auch spezielle sachen bei speziellen spenden."

WAS ES SCHON GAB, WAS FEHLTE
Drei Stufen ab Werk, fünf gezeichnete Zeichen, Farbe und Dauer je
Stufe — und sogar schon ein Feld für ein eigenes Bild. Es fehlte der
Weg, das alles zu ÄNDERN: Um eine Stufe umzubenennen, hätte jemand in
die Datenbank greifen müssen.

DIE GRÖSSE IST DAS „SPEZIELLE BEI SPEZIELLEN SPENDEN"
Je Stufe, nicht einmal für alle: Eine Rudel-Legende darf größer
dastehen als ein Danke. Eine einzige Größe für alle wäre wieder eine
Preisliste. Umgesetzt als EINE Schriftgröße, alles darin in `em` —
nicht `transform: scale()`, denn die Karte kommt schon mit
`translateX()` herein, und zwei `transform` an derselben Stelle
schließen einander aus; außerdem wird Text beim Skalieren matschig.
Nur nach oben (1 bis 2,5), und das ist eine ehrliche Grenze: Das
kleinste Wort auf der Karte steht bei 11,52 px, die Hausgrenze ist
11,5. Ein Faktor von 0,8 machte daraus 9,2 px. Kleiner geht an der
richtigen Stelle — die OBS-Tafel hat ihren eigenen Regler in der
Adresse, dort ist es eine Videoeinblendung und kein Text zum Lesen.

AUSPROBIEREN, OHNE EINE SPENDE ANZULEGEN
Der naheliegende Weg wäre gewesen: eine Spende eintragen und danach
löschen. Das ist verboten — in ein laufendes System kommen keine
Testdaten, und „gelöscht" heißt bei Geld nicht „war nie da". Die
Probe schreibt deshalb NICHTS und geht nur an den, der drückt; eine
Probe im ganzen Saal wäre eine Spende, die es nicht gab.

DREI FEHLER, DIE DIE PRÜFUNG GEFUNDEN HAT

1. `protokolliere()` wurde an 17 Stellen falsch herum gerufen —
   `(personId, aktion, detail, ip)` statt `(aktion, {…})`. JavaScript
   beschwert sich nicht: Das zweite Argument war ein Text, und einen
   Text zu zerlegen ergibt lauter `undefined`. Auf dem echten Server
   nachgemessen: 39 Protokollzeilen mit Aktionen wie „16.0", alle
   ohne Person, ohne Detail, ohne IP. Betroffen waren Material,
   Hilfe, Bühne, Reaction und Spenden — also jede Änderung an
   Dogi-Media und jede Maßnahme im Live-Chat, ausgerechnet das,
   wofür es ein Protokoll gibt. Alle 17 berichtigt, und
   pruef-struktur wacht jetzt darüber (mit Gegenprobe).

2. Beim Speichern der Leiter bekam jede Stufe eine NEUE Kennung
   (DELETE + INSERT). Ein Bild-Hochladen gegen die eben noch gültige
   Kennung antwortete mit 404 — im Alltag trifft das jeden, der einen
   zweiten Bildschirm offen hat. Jetzt werden vorhandene Zeilen
   geändert statt ersetzt; das Bild bleibt von selbst daran hängen.

3. `ab_cent` ist eindeutig. Zwei Stufen ihre Beträge tauschen zu
   lassen scheiterte mit „UNIQUE constraint failed", obwohl das
   Ergebnis in Ordnung gewesen wäre: Beim Umschreiben stößt die
   Leiter auf sich selbst. Jetzt in drei Schritten — löschen,
   geparkte Zwischenwerte, endgültige Werte —, und das ist nach
   außen nie sichtbar.

UND DREI, DIE IN MEINER MESSUNG STECKTEN
Die Messung hat eine noch laufende Karte aus dem vorigen Abschnitt
erwischt und daraus drei Fehler gemeldet, die keine waren —
darunter „die Probe läuft im ganzen Saal". Sie zählte außerdem die
versteckten Dateifelder als zu kleine Tippziele. Jetzt räumt sie
vorher auf, wartet auf die Karte MIT DER ERWARTETEN GRÖSSE (die
Karten laufen in einer Schlange — einen Knoten zu entfernen beendet
sie nicht) und lässt die Einblendung zur Ruhe kommen, bevor sie misst.
Ein Bildschirmfoto aus der Einblendphase sah aus, als stünde die
Karte links heraus; nachgemessen: links 18 px, ganz im Bild.

GEMESSEN, NICHT ANGENOMMEN
Karte bei Größe 1: Schrift 16 px, Betrag 25,92 px. Bei Größe 2:
32 px und 51,84 px — Faktor exakt 2,00. Hätte eine einzige Regel noch
in `rem` gestanden, wäre die Karte ungleichmäßig gewachsen, und auf
einem Bild sieht beides nur „größer" aus.

AUCH DAS BILD IST GEPRÜFT
Es liegt am Bühnen-Router und nicht am Spenden-Router: Die
Spendentafel in OBS hat keine Anmeldung, und ein 401 als JSON in
einem `<img>` ergibt ein kaputtes Bild ohne jeden Hinweis. Ohne
Schlüssel, aber mit 128 Bit zufälligem Dateinamen — dieselbe
Größenordnung wie der Bühnenschlüssel, und es ist ein Zierbild, das
ohnehin im Stream steht. Kein Ausbruch aus dem Ordner (vier Wege
geprüft, gemessen wird die Wirkung und nicht der Statuscode).

NACHGETRAGEN AUS BLOCK 4
`reaktion_meldungen` fehlte im Löschkonzept — eine bestehende Prüfung
hat es gefunden. 30 Tage nach dem Erledigen; meistens sind sie
ohnehin früher weg, weil der Live-Chat beim Beenden gelöscht wird und
die Meldungen daran hängen. Wer meldet, muss sich darauf verlassen
können, dass daraus keine dauerhafte Liste wird.

GEPRUEFT
pruef-spenden: 95 Prüfungen (vorher 46), 0 Fehler.
pruef-reaktion 260, pruef-buehne 36, pruef-aufbewahrung 45,
pruef-struktur, pruef-meldungen, pruef-css-klassen, pruef-tippziele,
pruef-deutsche-texte, pruef-auskunft alle grün.
mess-reaktion und mess-buehne: Rückgabewert 0, kein ACHTUNG.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-28 10:14:26 +02:00
co-authored by Claude Opus 5
parent 74c74a08ab
commit d2f02ba84e
61 changed files with 2140 additions and 714 deletions
+327 -5
View File
@@ -34,10 +34,18 @@ import express from "express";
import { db, sitzungLesen, protokolliere, echteIp } from "./workspace.js";
import { stromAn } from "./workspace-chat.js";
import { buehneMelden } from "./workspace-buehne.js";
import { join } from "node:path";
import { mkdirSync, writeFileSync, unlinkSync } from "node:fs";
import { randomBytes } from "node:crypto";
import { DATEN_ORDNER } from "./workspace.js";
import {
BETRAG_MAX_CENT, NAME_MAX, GRUSS_MAX, BETRAG_KNOEPFE, SPENDEN_TAGE,
VORLAGEN, STUFEN_MAX, DAUER_MS_MIN, DAUER_MS_MAX,
GROESSE_MIN, GROESSE_MAX, BILD_MAX_BYTES,
} from "./spenden-tabellen.js";
const BILDER = join(DATEN_ORDNER, "spendenbilder");
export const spendenRouter = express.Router();
/** Wer eine Spende bestaetigen, eintragen oder ablehnen darf.
@@ -152,12 +160,22 @@ function alsKarte(z) {
stand: z.stand,
erstellt: z.erstellt,
stufe: st ? {
name: st.name, vorlage: st.vorlage, bild: st.bild,
name: st.name, vorlage: st.vorlage, bild: bildAdresse(st.bild),
farbe: st.farbe, dauer_ms: st.dauer_ms,
groesse: Number(st.groesse) || 1,
} : null,
};
}
/** Aus dem Dateinamen wird eine Adresse -- oder nichts.
*
* Die Adresse zeigt auf den Buehnen-Router und NICHT hierher: Die
* Spendentafel in OBS hat keine Anmeldung, und an diesem Router
* steht eine Schranke ueber allem. */
function bildAdresse(datei) {
return datei ? `/workspace/api/buehne/spendenbild/${datei}` : "";
}
/** Eine bestaetigte Spende ins Bild schicken. */
function zeigen(z) {
db().prepare("UPDATE spenden SET gezeigt_am = ? WHERE id = ?").run(jetzt(), z.id);
@@ -185,8 +203,20 @@ spendenRouter.get("/workspace/api/spenden", (req, res) => {
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(),
stufen: db().prepare("SELECT id, ab_cent, name, vorlage, bild, farbe, dauer_ms,"
+ " groesse FROM spenden_stufen ORDER BY ab_cent").all()
.map((z) => ({ ...z, bild: bildAdresse(z.bild), groesse: Number(z.groesse) || 1 })),
/* WAS SICH EINSTELLEN LAESST, KOMMT VOM SERVER. Stuenden die
Vorlagennamen und die Grenzen auch in der Oberflaeche, waeren
es zwei Listen -- und die eine liefe irgendwann von der
anderen weg. Dann steht dort ein Knopf, den der Server
ablehnt. */
gestaltung: {
vorlagen: VORLAGEN, stufen_max: STUFEN_MAX,
dauer_min: DAUER_MS_MIN, dauer_max: DAUER_MS_MAX,
groesse_min: GROESSE_MIN, groesse_max: GROESSE_MAX,
bild_max_mb: Math.round(BILD_MAX_BYTES / 1024 / 1024),
},
betrag_max_cent: BETRAG_MAX_CENT,
ich: { darf },
/* Die offenen Meldungen sieht nur, wer sie bestaetigen kann.
@@ -267,7 +297,11 @@ spendenRouter.post("/workspace/api/spenden", nurGeld, express.json({ limit: "4kb
.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));
protokolliere("spende_hand", {
personId: req.person.id,
detail: `${cent} Cent von ${name}`,
ip: echteIp(req),
});
zeigen(z);
res.status(201).json(alsKarte(z));
} catch (fehler) {
@@ -300,7 +334,11 @@ spendenRouter.post("/workspace/api/spenden/:id(\\d+)/bestaetigen", nurGeld,
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));
protokolliere("spende_bestaetigt", {
personId: req.person.id,
detail: `#${z.id}, ${cent} Cent`,
ip: echteIp(req),
});
zeigen(neu);
res.json(alsKarte(neu));
} catch (fehler) {
@@ -324,6 +362,290 @@ spendenRouter.post("/workspace/api/spenden/:id(\\d+)/ablehnen", nurGeld, (req, r
/** Dieselbe Karte noch einmal zeigen — fuer den Fall, dass sie
* jemand verpasst hat oder die Technik im falschen Moment hakte. */
/* ======================================================================
DIE GESTALTUNG
Filipe: „mach paar fertige und so dass ich auch hochladen kann.
auch so dass ich das anders gestalten kann oder die groesse
veraendern kann. ... auch spezielle sachen bei speziellen spenden."
DIE GANZE LEITER AUF EINMAL UND NICHT ZEILE FUER ZEILE. Eine
Stufenleiter ist keine Liste unabhaengiger Zeilen -- sie muss als
Ganzes stimmen: aufsteigende Grenzen, keine zwei mit demselben
Betrag, mindestens eine. Wer einzeln speichert, geht durch
Zwischenzustaende, in denen sie das nicht tut, und waehrend einer
Sendung faellt dann eine Spende in die falsche Stufe.
====================================================================== */
spendenRouter.put("/workspace/api/spenden/stufen", nurGeld,
express.json({ limit: "16kb" }), (req, res) => {
try {
const roh = Array.isArray(req.body?.stufen) ? req.body.stufen : null;
if (!roh) return res.status(400).json({ fehler: "stufen_fehlen" });
if (!roh.length) return res.status(400).json({ fehler: "keine_stufe" });
if (roh.length > STUFEN_MAX) return res.status(400).json({ fehler: "zu_viele_stufen" });
const sauber = [];
for (const z of roh) {
const ab = Math.round(Number(z?.ab_cent));
if (!Number.isFinite(ab) || ab <= 0 || ab > BETRAG_MAX_CENT) {
return res.status(400).json({ fehler: "stufe_betrag_unklar" });
}
const name = String(z?.name ?? "").trim().slice(0, NAME_MAX);
if (!name) return res.status(400).json({ fehler: "stufe_name_fehlt" });
const vorlage = String(z?.vorlage ?? "herz");
if (!VORLAGEN.includes(vorlage)) {
return res.status(400).json({ fehler: "vorlage_unbekannt" });
}
/* NUR ECHTE FARBWERTE. Ein freier Text landete als CSS-Wert
in der Karte -- das ist genau die Stelle, an der aus einer
Einstellung eine Einschleusung wird. */
const farbe = String(z?.farbe ?? "").trim();
if (!/^#[0-9a-fA-F]{6}$/.test(farbe)) {
return res.status(400).json({ fehler: "farbe_unklar" });
}
const dauer = Math.round(Number(z?.dauer_ms));
if (!Number.isFinite(dauer) || dauer < DAUER_MS_MIN || dauer > DAUER_MS_MAX) {
return res.status(400).json({ fehler: "dauer_unklar" });
}
const groesse = Number(z?.groesse);
if (!Number.isFinite(groesse) || groesse < GROESSE_MIN || groesse > GROESSE_MAX) {
return res.status(400).json({ fehler: "groesse_unklar" });
}
sauber.push({ ab, name, vorlage, farbe, dauer, groesse, id: Number(z?.id) || 0 });
}
sauber.sort((a, b2) => a.ab - b2.ab);
for (let i = 1; i < sauber.length; i++) {
if (sauber[i].ab === sauber[i - 1].ab) {
return res.status(409).json({ fehler: "stufe_doppelt" });
}
}
/* ==== DIE KENNUNGEN MUESSEN DAS SPEICHERN UEBERLEBEN =======
Der erste Bau hat hier `DELETE` und dann `INSERT` gemacht.
Das Ergebnis war richtig -- und trotzdem falsch: Jede Stufe
bekam dabei eine NEUE Kennung. Gemessen ist das in der
Pruefung aufgefallen, als ein Bild-Hochladen gegen die eben
noch gueltige Kennung mit 404 antwortete.
Im Alltag heisst das: Wer die Leiter speichert und dann auf
„Bild hochladen" drueckt, ohne dass die Seite dazwischen neu
gezeichnet wurde, greift ins Leere. Und ein zweiter offener
Bildschirm greift IMMER ins Leere.
Also: vorhandene Zeilen AENDERN, neue anlegen, entfernte
loeschen. Dann bleibt die Kennung, und das Bild bleibt von
selbst daran haengen -- ohne eine Landkarte, die man pflegen
muesste. */
const vorhanden = db().prepare("SELECT id, bild FROM spenden_stufen").all();
const kenneIch = new Set(vorhanden.map((z) => z.id));
const behalten = new Set();
/* EIN SCHREIBVORGANG. `node:sqlite` kennt kein `.transaction()`,
also von Hand -- ein Abbruch mittendrin haette eine Leiter
zurueckgelassen, die halb die alte und halb die neue ist. */
db().exec("BEGIN IMMEDIATE");
try {
/* ==== DREI SCHRITTE, UND DIE REIHENFOLGE IST DER PUNKT ====
`ab_cent` ist EINDEUTIG. Wer die Zeilen einfach der Reihe
nach umschreibt, stoesst mitten im Vorgang auf sich
selbst: Die 500er-Stufe auf 100 zu setzen scheitert,
solange die alte 100er noch dasteht -- obwohl das ERGEBNIS
voellig in Ordnung waere. Gemessen ist genau das passiert:
„UNIQUE constraint failed: spenden_stufen.ab_cent", und
der ganze Vorgang brach ab.
Also erst alles aus dem Weg raeumen: Was weg soll, wird
geloescht; was bleibt, wandert kurz auf einen Betrag, den
niemand sonst hat (eine Milliarde plus die eigene
Kennung). Danach kann jede Zeile ihren endgueltigen Wert
bekommen, ohne einer anderen in die Quere zu kommen.
Das ist nur INNERHALB des Schreibvorgangs sichtbar -- nach
aussen gibt es diesen Zwischenzustand nie. */
for (const z of vorhanden) {
const bleibt = sauber.some((n) => n.id === z.id);
if (!bleibt) db().prepare("DELETE FROM spenden_stufen WHERE id = ?").run(z.id);
else {
db().prepare("UPDATE spenden_stufen SET ab_cent = ? WHERE id = ?")
.run(1000000000 + z.id, z.id);
}
}
const aendern = db().prepare(`UPDATE spenden_stufen SET
ab_cent = ?, name = ?, vorlage = ?, farbe = ?, dauer_ms = ?,
groesse = ?, geaendert = ? WHERE id = ?`);
const ein = db().prepare(`INSERT INTO spenden_stufen
(ab_cent, name, vorlage, bild, farbe, dauer_ms, groesse, geaendert)
VALUES (?,?,?,'',?,?,?,?)`);
for (const z of sauber) {
if (z.id && kenneIch.has(z.id)) {
aendern.run(z.ab, z.name, z.vorlage, z.farbe, z.dauer, z.groesse, jetzt(), z.id);
behalten.add(z.id);
} else {
const { lastInsertRowid } = ein.run(
z.ab, z.name, z.vorlage, z.farbe, z.dauer, z.groesse, jetzt());
behalten.add(Number(lastInsertRowid));
}
}
db().exec("COMMIT");
} catch (f) {
db().exec("ROLLBACK");
throw f;
}
/* WAS AN BILDERN UEBRIG BLEIBT, WIRD WEGGERAEUMT. Sonst
sammelt sich bei jeder geloeschten Stufe eine Datei an, die
niemand mehr findet und niemand mehr loescht. */
for (const z of vorhanden) {
if (z.bild && !behalten.has(z.id)) {
try { unlinkSync(join(BILDER, z.bild)); } catch { /* war schon weg */ }
}
}
protokolliere("spenden_stufen", {
personId: req.person.id,
detail: `${sauber.length} Stufen gespeichert`,
ip: echteIp(req),
});
res.json({ ok: true, stufen: stufenLesen() });
} catch (fehler) {
console.error("[spenden] Stufen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
function stufenLesen() {
return db().prepare("SELECT id, ab_cent, name, vorlage, bild, farbe, dauer_ms,"
+ " groesse FROM spenden_stufen ORDER BY ab_cent").all()
.map((z) => ({ ...z, bild: bildAdresse(z.bild), groesse: Number(z.groesse) || 1 }));
}
/** Woran man ein Bild erkennt -- an seinem Inhalt und nicht an seinem
* Namen. Eine Datei „katze.png", die etwas anderes enthaelt, waere
* sonst ein Bild, das der Browser als etwas anderes liest. */
function bildart(puffer) {
if (!puffer || puffer.length < 12) return null;
if (puffer[0] === 0xff && puffer[1] === 0xd8 && puffer[2] === 0xff) return "jpg";
if (puffer[0] === 0x89 && puffer[1] === 0x50 && puffer[2] === 0x4e && puffer[3] === 0x47) {
return "png";
}
if (puffer.toString("ascii", 0, 4) === "RIFF" && puffer.toString("ascii", 8, 12) === "WEBP") {
return "webp";
}
return null;
}
/* Ein zu grosses Bild ist kein Serverfehler, sondern eine ganz
normale Eingabe -- und der Absender soll erfahren, WAS zu tun ist.
Ohne diese Stelle laeuft der Fehler von express.raw() bis in die
allgemeine Behandlung durch und kommt als „500 Serverfehler" an. */
function zuGross(fehler, req, res, next) {
if (fehler?.type === "entity.too.large") {
return res.status(413).json({ fehler: "bild_zu_gross" });
}
return next(fehler);
}
spendenRouter.post("/workspace/api/spenden/stufe/:id(\\d+)/bild", nurGeld,
express.raw({ type: "*/*", limit: BILD_MAX_BYTES }), zuGross, (req, res) => {
try {
const id = Number(req.params.id);
const stufe = db().prepare("SELECT id, bild FROM spenden_stufen WHERE id = ?").get(id);
if (!stufe) return res.status(404).json({ fehler: "nicht_gefunden" });
const endung = bildart(req.body);
if (!endung) return res.status(400).json({ fehler: "kein_bild" });
mkdirSync(BILDER, { recursive: true });
const name = `${randomBytes(16).toString("hex")}.${endung}`;
writeFileSync(join(BILDER, name), req.body);
db().prepare("UPDATE spenden_stufen SET bild = ?, geaendert = ? WHERE id = ?")
.run(name, jetzt(), id);
/* Das alte wegraeumen -- sonst bleibt bei jedem Wechsel eine
Datei liegen, die niemand mehr findet. */
if (stufe.bild && stufe.bild !== name) {
try { unlinkSync(join(BILDER, stufe.bild)); } catch { /* war schon weg */ }
}
protokolliere("spenden_bild", {
personId: req.person.id,
detail: `Stufe ${id}, ${Math.round(req.body.length / 1024)} KB`,
ip: echteIp(req),
});
res.json({ ok: true, bild: bildAdresse(name), stufen: stufenLesen() });
} catch (fehler) {
console.error("[spenden] Bild:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
spendenRouter.delete("/workspace/api/spenden/stufe/:id(\\d+)/bild", nurGeld, (req, res) => {
try {
const id = Number(req.params.id);
const stufe = db().prepare("SELECT id, bild FROM spenden_stufen WHERE id = ?").get(id);
if (!stufe) return res.status(404).json({ fehler: "nicht_gefunden" });
db().prepare("UPDATE spenden_stufen SET bild = '', geaendert = ? WHERE id = ?")
.run(jetzt(), id);
if (stufe.bild) { try { unlinkSync(join(BILDER, stufe.bild)); } catch { /* war weg */ } }
res.json({ ok: true, stufen: stufenLesen() });
} catch (fehler) {
console.error("[spenden] Bild loeschen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Einmal ansehen, wie es aussieht -- OHNE eine Spende anzulegen.
*
* ==== DAS IST DER GANZE PUNKT DIESER ROUTE ==========================
*
* Wer eine Stufe gestaltet, will sehen, was herauskommt. Der
* naheliegende Weg waere, eine Spende von Hand einzutragen und
* danach zu loeschen -- und genau das ist verboten: In ein laufendes
* System kommen keine Testdaten. „Storniert heisst nicht weg"; der
* Eintrag stuende trotzdem im Verlauf, in der Summe und in der
* Aufbewahrung, und bei Geld ist das nicht nur unordentlich.
*
* Diese Route schreibt deshalb NICHTS. Sie baut eine Karte aus den
* aktuellen Stufen und schickt sie los. Und sie geht NUR an den,
* der drueckt -- eine Probe, die im ganzen Saal erscheint, waere
* eine Spende, die es nicht gab. */
spendenRouter.post("/workspace/api/spenden/probe", nurGeld,
express.json({ limit: "2kb" }), (req, res) => {
try {
const cent = Math.round(Number(req.body?.betrag_cent));
if (!Number.isFinite(cent) || cent <= 0 || cent > BETRAG_MAX_CENT) {
return res.status(400).json({ fehler: "betrag_unklar" });
}
const st = stufeFuer(cent);
const karte = {
id: 0,
betrag_cent: cent,
waehrung: "EUR",
/* KENNTLICH ALS PROBE. Stuende hier ein Name, waere auf dem
Bildschirmfoto nicht mehr zu unterscheiden, ob jemand
gespendet hat. */
name: "Probe",
gruss: "So sieht es aus",
herkunft: "hand",
stand: "probe",
erstellt: jetzt(),
stufe: st ? {
name: st.name, vorlage: st.vorlage, bild: bildAdresse(st.bild),
farbe: st.farbe, dauer_ms: st.dauer_ms, groesse: Number(st.groesse) || 1,
} : null,
};
stromAn([req.person.id], "spende", { was: "zeigen", karte });
res.json({ ok: true, karte });
} catch (fehler) {
console.error("[spenden] Probe:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
spendenRouter.post("/workspace/api/spenden/:id(\\d+)/nochmal", nurGeld, (req, res) => {
try {
const z = db().prepare("SELECT * FROM spenden WHERE id = ? AND stand = 'bestaetigt'")