/* ===================================================================== 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) }); }