Files
DogFatherGitandClaude Opus 5 bda3198c76 Neue Funktion: Supporter-Abstimmungen (Verwaltung + DogiCrew-Bereich)
Naechste "Ausbaustufe" aus Dogfather_VanVan_Supporter_Abo.odt Abschnitt 20
(siehe Supporter-Abo-System.md), auf Nutzerwunsch "perfektioniere meine
Verwaltungsseite": Dogi/VanVan koennen in verwaltung.html eine Frage mit
2-6 Antwortoptionen auf Deutsch erstellen, automatische Uebersetzung beim
Speichern (gleiches Muster wie "Event des Jahres"). Jede aktive DogiCrew-
Person sieht die Abstimmung in ihrem Supporter-Bereich, stimmt genau einmal
ab (UNIQUE-Constraint in der DB, nicht nur Anwendungslogik), sieht danach
die Live-Ergebnisse. Admin-Seite zeigt Ergebnisbalken live, kann schliessen/
wiedereroeffnen/loeschen.

- Neue Migration 0005_supporter_polls.sql (supporter_polls,
  supporter_poll_votes), neue Berechtigung POLLS_MANAGE.
- server-internal/routes/polls.js: 30 End-to-End-Tests gegen eine
  Fake-DB bestanden (better-sqlite3 laesst sich lokal nicht kompilieren).
- verwaltung.html: neue "Abstimmungen"-Kiste im bestehenden
  vw-overview-box-Stil (violett/pink Ergebnisbalken).
- supporter.html: neue "Aktuelle Abstimmung"-Karte im bestehenden
  Gold-Look, Optionen -> Stimme -> Ergebnisbalken, alle 5 Sprachen.

Backend-Teil (server-internal/, cloudflare-worker/migrations/) noch ohne
Deploy-Zugriff -- Dogi muss ihn manuell auf dogiintern ausrollen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 16:07:51 +02:00

179 lines
7.8 KiB
JavaScript

/* =====================================================================
routes/polls.js — Supporter-Abstimmungen (20.08.2026), "nächste
Ausbaustufe" aus Dogfather_VanVan_Supporter_Abo.odt Abschnitt 20.
Admin-Seite (verwaltung.html): Frage + Antwortoptionen auf Deutsch
erstellen (POLLS_MANAGE-Recht, Owner immer erlaubt), automatische
Übersetzung wie bei "Event des Jahres" (lib/translate.js), Ergebnisse
live einsehen, Abstimmung schließen oder löschen.
Supporter-Bereich: genau EINE aktive Abstimmung wird angezeigt (die
zuletzt erstellte), einmal Stimme abgeben (UNIQUE(poll_id, supporter_id)
in der Datenbank verhindert doppelte Stimmen robust, auch bei
gleichzeitigen Anfragen — keine reine Anwendungslogik-Prüfung).
===================================================================== */
import { db } from "../db.js";
import { generateId, nowIso } from "../lib/crypto.js";
import { hasPermission } from "../lib/permissions.js";
import { json } from "../lib/http.js";
import { logAction } from "../lib/audit.js";
import { uebersetzeAlleSprachen } from "../lib/translate.js";
const MIN_OPTIONEN = 2;
const MAX_OPTIONEN = 6;
function can(session, key) {
if (!session) return false;
if (session.isOwner) return true;
return hasPermission(session.permissions, session.overrides, key);
}
function forbidden(res) {
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
}
function parsePoll(row) {
return {
id: row.id,
question: JSON.parse(row.question),
options: JSON.parse(row.options),
status: row.status,
createdAt: row.created_at,
createdBy: row.created_by,
closedAt: row.closed_at,
};
}
function voteCountsFor(pollId, optionCount) {
const rows = db
.prepare(`SELECT option_index, COUNT(*) as n FROM supporter_poll_votes WHERE poll_id = ? GROUP BY option_index`)
.all(pollId);
const counts = new Array(optionCount).fill(0);
for (const r of rows) {
if (r.option_index >= 0 && r.option_index < optionCount) counts[r.option_index] = r.n;
}
return counts;
}
/* ---------- Admin (interne Team-Session, POLLS_MANAGE) ---------- */
export async function createPoll(req, res, session) {
if (!can(session, "POLLS_MANAGE")) return forbidden(res);
const body = req.body || {};
const fragestellungDe = String(body.frage || "").trim().slice(0, 200);
const optionenDe = Array.isArray(body.optionen)
? body.optionen.map((o) => String(o || "").trim().slice(0, 100)).filter(Boolean)
: [];
if (!fragestellungDe) return json(res, { ok: false, error: "Bitte eine Frage eingeben." }, 400);
if (optionenDe.length < MIN_OPTIONEN) {
return json(res, { ok: false, error: `Mindestens ${MIN_OPTIONEN} Antwortoptionen nötig.` }, 400);
}
if (optionenDe.length > MAX_OPTIONEN) {
return json(res, { ok: false, error: `Höchstens ${MAX_OPTIONEN} Antwortoptionen erlaubt.` }, 400);
}
const question = await uebersetzeAlleSprachen(fragestellungDe);
const options = await Promise.all(optionenDe.map((o) => uebersetzeAlleSprachen(o)));
const id = generateId();
const jetzt = nowIso();
db.prepare(
`INSERT INTO supporter_polls (id, question, options, status, created_at, created_by)
VALUES (?, ?, ?, 'active', ?, ?)`
).run(id, JSON.stringify(question), JSON.stringify(options), jetzt, session.actor);
logAction(session.actor, "polls.created", id, { frage: fragestellungDe });
return json(res, { ok: true, poll: { id, question, options, status: "active", createdAt: jetzt, closedAt: null } });
}
export async function listPolls(req, res, session) {
if (!can(session, "POLLS_MANAGE")) return forbidden(res);
const rows = db.prepare(`SELECT * FROM supporter_polls ORDER BY created_at DESC`).all();
const polls = rows.map((row) => {
const p = parsePoll(row);
const counts = voteCountsFor(p.id, p.options.length);
return { ...p, counts, totalVotes: counts.reduce((a, b) => a + b, 0) };
});
return json(res, { ok: true, polls });
}
export async function closePoll(req, res, session) {
if (!can(session, "POLLS_MANAGE")) return forbidden(res);
const id = String(req.body?.pollId || "");
const row = db.prepare(`SELECT id, status FROM supporter_polls WHERE id = ?`).get(id);
if (!row) return json(res, { ok: false, error: "Abstimmung nicht gefunden." }, 404);
db.prepare(`UPDATE supporter_polls SET status = 'closed', closed_at = ? WHERE id = ?`).run(nowIso(), id);
logAction(session.actor, "polls.closed", id, null);
return json(res, { ok: true });
}
export async function reopenPoll(req, res, session) {
if (!can(session, "POLLS_MANAGE")) return forbidden(res);
const id = String(req.body?.pollId || "");
const row = db.prepare(`SELECT id FROM supporter_polls WHERE id = ?`).get(id);
if (!row) return json(res, { ok: false, error: "Abstimmung nicht gefunden." }, 404);
db.prepare(`UPDATE supporter_polls SET status = 'active', closed_at = NULL WHERE id = ?`).run(id);
logAction(session.actor, "polls.reopened", id, null);
return json(res, { ok: true });
}
export async function deletePoll(req, res, session) {
if (!can(session, "POLLS_MANAGE")) return forbidden(res);
const id = String(req.body?.pollId || "");
db.prepare(`DELETE FROM supporter_poll_votes WHERE poll_id = ?`).run(id);
const info = db.prepare(`DELETE FROM supporter_polls WHERE id = ?`).run(id);
if (info.changes === 0) return json(res, { ok: false, error: "Abstimmung nicht gefunden." }, 404);
logAction(session.actor, "polls.deleted", id, null);
return json(res, { ok: true });
}
/* ---------- Supporter-Bereich (eigene Session, jeder aktive Supporter) ---------- */
export async function getActivePollPublic(req, res, supporter) {
const row = db.prepare(`SELECT * FROM supporter_polls WHERE status = 'active' ORDER BY created_at DESC LIMIT 1`).get();
if (!row) return json(res, { ok: true, poll: null });
const poll = parsePoll(row);
const meineStimme = db
.prepare(`SELECT option_index FROM supporter_poll_votes WHERE poll_id = ? AND supporter_id = ?`)
.get(poll.id, supporter.id);
const antwort = { ok: true, poll: { id: poll.id, question: poll.question, options: poll.options }, myVote: meineStimme ? meineStimme.option_index : null };
// Ergebnisse nur zeigen, wenn schon abgestimmt wurde (sonst könnte das
// Ergebnis die eigene Stimme beeinflussen) — dieselbe Logik wie bei
// öffentlichen Umfrage-Tools üblich.
if (meineStimme) {
const counts = voteCountsFor(poll.id, poll.options.length);
antwort.counts = counts;
antwort.totalVotes = counts.reduce((a, b) => a + b, 0);
}
return json(res, antwort);
}
export async function voteOnPoll(req, res, supporter) {
const pollId = String(req.body?.pollId || "");
const optionIndex = Number(req.body?.optionIndex);
const poll = db.prepare(`SELECT * FROM supporter_polls WHERE id = ? AND status = 'active'`).get(pollId);
if (!poll) return json(res, { ok: false, error: "Diese Abstimmung ist nicht mehr aktiv." }, 404);
const options = JSON.parse(poll.options);
if (!Number.isInteger(optionIndex) || optionIndex < 0 || optionIndex >= options.length) {
return json(res, { ok: false, error: "Ungültige Antwortoption." }, 400);
}
try {
db.prepare(
`INSERT INTO supporter_poll_votes (id, poll_id, supporter_id, option_index, voted_at) VALUES (?, ?, ?, ?, ?)`
).run(generateId(), pollId, supporter.id, optionIndex, nowIso());
} catch (err) {
// UNIQUE(poll_id, supporter_id) verhindert Doppelstimmen — sauber statt
// als Serverfehler durchzureichen.
if (String(err?.message || "").includes("UNIQUE")) {
return json(res, { ok: false, error: "Du hast bei dieser Abstimmung bereits mitgemacht." }, 409);
}
throw err;
}
logAction("system", "polls.voted", pollId, { supporterId: supporter.id, optionIndex });
const counts = voteCountsFor(pollId, options.length);
return json(res, { ok: true, counts, totalVotes: counts.reduce((a, b) => a + b, 0) });
}