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]>
179 lines
7.8 KiB
JavaScript
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) });
|
|
}
|