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]>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
/* =====================================================================
|
||||
db.js — SQLite-Datenbank (better-sqlite3), Ersatz für Cloudflare D1.
|
||||
Führt beim ersten Start alle vier Migrationen aus ../cloudflare-worker/migrations/
|
||||
Führt beim ersten Start alle Migrationen aus ../cloudflare-worker/migrations/
|
||||
in der ursprünglichen Reihenfolge aus (0001 Basis-Schema, 0002 app_settings,
|
||||
0003 Supporter-Abo, 0004 TikTok/Discord-Spalten) — anders als bei VanVans Shop
|
||||
ist hier NIE alles in einer konsolidierten schema.sql zusammengefasst worden,
|
||||
deshalb müssen wir hier tatsächlich alle vier der Reihe nach anwenden.
|
||||
0003 Supporter-Abo, 0004 TikTok/Discord-Spalten, 0005 Supporter-Abstimmungen)
|
||||
— anders als bei VanVans Shop ist hier NIE alles in einer konsolidierten
|
||||
schema.sql zusammengefasst worden, deshalb müssen wir hier tatsächlich alle
|
||||
der Reihe nach anwenden.
|
||||
===================================================================== */
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
@@ -27,6 +28,7 @@ const MIGRATION_FILES = [
|
||||
"0002_app_settings.sql",
|
||||
"0003_supporter_abo.sql",
|
||||
"0004_supporter_oauth_providers.sql",
|
||||
"0005_supporter_polls.sql",
|
||||
];
|
||||
|
||||
function alreadyApplied(name) {
|
||||
|
||||
@@ -64,6 +64,9 @@ import {
|
||||
listSupporters, getSupporterDetail, updatePremium, listCollections, upsertCollection, getAdminOverview,
|
||||
} from "./routes/supporter-admin.js";
|
||||
import { getEventsPublic, saveEvent, uploadEventImage } from "./routes/events.js";
|
||||
import {
|
||||
createPoll, listPolls, closePoll, reopenPoll, deletePoll, getActivePollPublic, voteOnPoll,
|
||||
} from "./routes/polls.js";
|
||||
|
||||
initDb();
|
||||
|
||||
@@ -219,6 +222,13 @@ app.post("/admin/supporter/premium/update", requireTeamSession, withSession(upda
|
||||
app.post("/admin/supporter/collections/list", requireTeamSession, withSession(listCollections));
|
||||
app.post("/admin/supporter/collections/upsert", requireTeamSession, withSession(upsertCollection));
|
||||
|
||||
// Supporter-Abstimmungen (20.08.2026)
|
||||
app.post("/admin/polls/list", requireTeamSession, withSession(listPolls));
|
||||
app.post("/admin/polls/create", requireTeamSession, withSession(createPoll));
|
||||
app.post("/admin/polls/close", requireTeamSession, withSession(closePoll));
|
||||
app.post("/admin/polls/reopen", requireTeamSession, withSession(reopenPoll));
|
||||
app.post("/admin/polls/delete", requireTeamSession, withSession(deletePoll));
|
||||
|
||||
/* ---------- Öffentlicher Supporter-Bereich (eigene Session, requireSupporterAuth) ---------- */
|
||||
|
||||
app.post("/supporter/logout", requireSupporterAuth, wrap(logoutSupporter));
|
||||
@@ -231,6 +241,8 @@ app.post("/supporter/delete-account", requireSupporterAuth, withSupporter(delete
|
||||
app.post("/supporter/paypal/start", requireSupporterAuth, withSupporter(startPaypalSubscription));
|
||||
app.post("/supporter/paypal/confirm", requireSupporterAuth, withSupporter(confirmPaypalSubscription));
|
||||
app.post("/supporter/paypal/cancel", requireSupporterAuth, withSupporter(cancelPaypalSubscription));
|
||||
app.post("/supporter/polls/active", requireSupporterAuth, withSupporter(getActivePollPublic));
|
||||
app.post("/supporter/polls/vote", requireSupporterAuth, withSupporter(voteOnPoll));
|
||||
|
||||
app.use((req, res) => json(res, { ok: false, error: "Not found." }, 404));
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export const PERMISSIONS = {
|
||||
SUPPORTER_VIEW: "Supporter-Abonnenten ansehen",
|
||||
SUPPORTER_MANAGE_PREMIUMS: "Supporter-Prämien & Versand verwalten",
|
||||
SUPPORTER_MANAGE_COLLECTIONS: "Supporter-Kollektionen verwalten",
|
||||
POLLS_MANAGE: "Supporter-Abstimmungen erstellen & auswerten",
|
||||
|
||||
EVENTS_MANAGE: "Startseite: \"Event des Jahres\" verwalten",
|
||||
};
|
||||
@@ -56,7 +57,7 @@ export const PERMISSION_GROUPS = [
|
||||
{ label: "Interne Notizen", keys: ["NOTES_READ", "NOTES_WRITE", "NOTES_EDIT_OWN", "NOTES_EDIT_OTHERS", "NOTES_DELETE"] },
|
||||
{ label: "Teamverwaltung", keys: ["TEAM_VIEW", "TEAM_PREPARE_USER", "TEAM_LOCK_USER", "TEAM_ASSIGN_ROLE", "TEAM_CREATE_ROLE", "TEAM_EDIT_PERMISSIONS"] },
|
||||
{ label: "Einstellungen", keys: ["SETTINGS_EDIT_FORMS", "SETTINGS_EDIT_STATUS_TYPES", "SETTINGS_VIEW_STATS", "SETTINGS_VIEW_ACTIVITY_LOG", "SETTINGS_EDIT_GENERAL"] },
|
||||
{ label: "DogiCrew 🩵", keys: ["SUPPORTER_VIEW", "SUPPORTER_MANAGE_PREMIUMS", "SUPPORTER_MANAGE_COLLECTIONS"] },
|
||||
{ label: "DogiCrew 🩵", keys: ["SUPPORTER_VIEW", "SUPPORTER_MANAGE_PREMIUMS", "SUPPORTER_MANAGE_COLLECTIONS", "POLLS_MANAGE"] },
|
||||
{ label: "Startseite", keys: ["EVENTS_MANAGE"] },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/* =====================================================================
|
||||
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) });
|
||||
}
|
||||
Reference in New Issue
Block a user