Postfach/Team-Verwaltung/Supporter-Abo als Node.js/Express-Server portiert
Vollständiger 1:1-Port des cloudflare-worker/ (dogfather-universe-postfach) auf server-internal/: Bewerbungs-Postfach mit Rollen/Rechten, Team- Zugangsverwaltung (verschlüsselte Owner-only-Codes), DogiCrew-Supporter-Abo (PayPal Subscriptions, Google-Login, Resend-E-Mail, 30-Monats-Prämienzyklus) und automatische TikTok-Live-Erkennung. D1 -> better-sqlite3, Cloudflare- Cron -> node-cron. Läuft ohne gesetzte Secrets weiter (PayPal/Google/Resend/ Ticketanizer/Discord bleiben "nicht konfiguriert" statt kaputt), bis die echten Zugangsdaten eingetragen werden.
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
/* =====================================================================
|
||||
routes/applications.js — Bewerbungen (Postfach), mit vollem Rollen-/
|
||||
Rechte-Modell statt einem pauschalen Owner/Helfer-Zugriff. Status,
|
||||
Zuweisung, Notizen und Antwortentwürfe pro Bewerbung. 1:1 portiert aus
|
||||
cloudflare-worker/src/routes/applications.js.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { generateId, nowIso } from "../lib/crypto.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { bewerbungAlsTicketAnlegen } from "../lib/ticketanizer.js";
|
||||
import { bewerbungAlsDiscordNachrichtPosten } from "../lib/discord-webhooks.js";
|
||||
|
||||
const ALLOWED_TYPES = ["creator", "scout", "kooperation", "modi"];
|
||||
|
||||
function can(session, key) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return hasPermission(session.permissions, session.overrides, key);
|
||||
}
|
||||
|
||||
function viewPermissionForType(type) {
|
||||
// Der Rechte-Katalog kommt fest aus dem Anforderungsdokument und kennt nur
|
||||
// zwei Bewerbungs-Sichtbereiche (Creator / "Rest"). Scout, Kooperation und
|
||||
// Modi laufen deshalb alle über APPLICATIONS_VIEW_MANAGER_SCOUT — es wird
|
||||
// bewusst KEIN neuer Permission-Key erfunden, der nicht im Dokument steht.
|
||||
return type === "creator" ? "APPLICATIONS_VIEW_CREATOR" : "APPLICATIONS_VIEW_MANAGER_SCOUT";
|
||||
}
|
||||
|
||||
export async function submitApplication(req, res) {
|
||||
const body = req.body;
|
||||
const { type, data } = body || {};
|
||||
if (!ALLOWED_TYPES.includes(type) || typeof data !== "object" || !data) {
|
||||
return json(res, { ok: false, error: "Ungültige Bewerbungsdaten." }, 400);
|
||||
}
|
||||
const values = Object.values(data).map((v) => String(v || "").trim());
|
||||
if (values.every((v) => v === "")) return json(res, { ok: false, error: "Leere Bewerbung." }, 400);
|
||||
|
||||
const id = generateId();
|
||||
db.prepare(`INSERT INTO applications (id, type, data, status, archived, created_at) VALUES (?, ?, ?, 'neu', 0, ?)`).run(
|
||||
id,
|
||||
type,
|
||||
JSON.stringify(data),
|
||||
nowIso()
|
||||
);
|
||||
|
||||
// Zusätzlich (rein informativ, blockiert die Bewerbung selbst nie):
|
||||
// 1) echtes Ticket bei Ticketanizer im passenden Bereich anlegen
|
||||
// (Web-Inbox zum Bearbeiten/Antworten)
|
||||
// 2) direkte, garantiert sichtbare Discord-Nachricht in den passenden
|
||||
// Kanal posten (Ticketanizer erzeugt bei API-Tickets keinen echten
|
||||
// Discord-Kanal, siehe discord-webhooks.js)
|
||||
// Läuft im Hintergrund weiter (kein ctx.waitUntil-Äquivalent in Express
|
||||
// nötig — der Node-Prozess bleibt am Leben), blockiert aber die Antwort
|
||||
// an die bewerbende Person nicht.
|
||||
Promise.all([bewerbungAlsTicketAnlegen(type, data), bewerbungAlsDiscordNachrichtPosten(type, data)]).catch(() => {});
|
||||
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function listApplications(req, res, session) {
|
||||
if (!session) return json(res, { ok: false, error: "Nicht angemeldet." }, 401);
|
||||
const body = req.body || {};
|
||||
const includeArchived = !!body.includeArchived;
|
||||
|
||||
const allowedTypes = ALLOWED_TYPES.filter((t) => session.isOwner || can(session, viewPermissionForType(t)));
|
||||
if (allowedTypes.length === 0) return json(res, { ok: true, applications: [] });
|
||||
if (includeArchived && !session.isOwner && !can(session, "APPLICATIONS_VIEW_ARCHIVED")) {
|
||||
return json(res, { ok: false, error: "Keine Berechtigung für archivierte Bewerbungen." }, 403);
|
||||
}
|
||||
|
||||
const placeholders = allowedTypes.map(() => "?").join(",");
|
||||
const archivedClause = includeArchived ? "" : "AND archived = 0";
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM applications WHERE type IN (${placeholders}) ${archivedClause} ORDER BY created_at DESC LIMIT 300`)
|
||||
.all(...allowedTypes);
|
||||
|
||||
const applications = rows.map((r) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
data: session.isOwner || can(session, "APPLICATIONS_OPEN_FULL") ? JSON.parse(r.data) : null,
|
||||
status: r.status,
|
||||
assignedTo: r.assigned_to,
|
||||
archived: !!r.archived,
|
||||
createdAt: r.created_at,
|
||||
}));
|
||||
|
||||
return json(res, { ok: true, applications });
|
||||
}
|
||||
|
||||
export async function changeApplicationStatus(req, res, session) {
|
||||
if (!can(session, "APPLICATIONS_CHANGE_STATUS")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
const status = String(body.status || "").slice(0, 40);
|
||||
db.prepare(`UPDATE applications SET status = ? WHERE id = ?`).run(status, id);
|
||||
logAction(session.actor, "application.status", id, { status });
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function takeOverApplication(req, res, session) {
|
||||
if (!can(session, "APPLICATIONS_TAKE_OVER")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
const who = session.isOwner ? "owner" : session.userId;
|
||||
db.prepare(`UPDATE applications SET assigned_to = ? WHERE id = ?`).run(who, id);
|
||||
logAction(session.actor, "application.take_over", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function assignApplication(req, res, session) {
|
||||
if (!can(session, "APPLICATIONS_ASSIGN")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
const assignedTo = body.assignedTo ? String(body.assignedTo) : null;
|
||||
db.prepare(`UPDATE applications SET assigned_to = ? WHERE id = ?`).run(assignedTo, id);
|
||||
logAction(session.actor, "application.assign", id, { assignedTo });
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function archiveApplication(req, res, session) {
|
||||
if (!can(session, "APPLICATIONS_ARCHIVE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
db.prepare(`UPDATE applications SET archived = 1 WHERE id = ?`).run(id);
|
||||
logAction(session.actor, "application.archive", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function restoreApplication(req, res, session) {
|
||||
if (!can(session, "APPLICATIONS_RESTORE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
db.prepare(`UPDATE applications SET archived = 0 WHERE id = ?`).run(id);
|
||||
logAction(session.actor, "application.restore", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function deleteApplication(req, res, session) {
|
||||
if (!can(session, "APPLICATIONS_DELETE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
db.prepare(`DELETE FROM application_notes WHERE application_id = ?`).run(id);
|
||||
db.prepare(`DELETE FROM application_drafts WHERE application_id = ?`).run(id);
|
||||
db.prepare(`DELETE FROM applications WHERE id = ?`).run(id);
|
||||
logAction(session.actor, "application.delete", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function markAnswered(req, res, session) {
|
||||
if (!can(session, "APPLICATIONS_MARK_ANSWERED")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
db.prepare(`UPDATE applications SET status = 'beantwortet' WHERE id = ?`).run(id);
|
||||
logAction(session.actor, "application.mark_answered", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/* ---- Notizen ---- */
|
||||
export async function listNotes(req, res, session) {
|
||||
if (!can(session, "NOTES_READ")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM application_notes WHERE application_id = ? ORDER BY created_at ASC`)
|
||||
.all(String(body.applicationId || ""));
|
||||
return json(res, { ok: true, notes: rows });
|
||||
}
|
||||
|
||||
export async function addNote(req, res, session) {
|
||||
if (!can(session, "NOTES_WRITE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const text = String(body.text || "").slice(0, 2000);
|
||||
if (!text.trim()) return json(res, { ok: false, error: "Leere Notiz." }, 400);
|
||||
db.prepare(`INSERT INTO application_notes (application_id, author, text, created_at) VALUES (?, ?, ?, ?)`).run(
|
||||
String(body.applicationId || ""),
|
||||
session.actor,
|
||||
text,
|
||||
nowIso()
|
||||
);
|
||||
logAction(session.actor, "note.add", body.applicationId, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function deleteNote(req, res, session) {
|
||||
if (!can(session, "NOTES_DELETE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
db.prepare(`DELETE FROM application_notes WHERE id = ?`).run(Number(body.id));
|
||||
logAction(session.actor, "note.delete", String(body.id), null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/* ---- Antwortentwürfe ---- */
|
||||
export async function listDrafts(req, res, session) {
|
||||
const body = req.body || {};
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM application_drafts WHERE application_id = ? ORDER BY updated_at DESC`)
|
||||
.all(String(body.applicationId || ""));
|
||||
return json(res, { ok: true, drafts: rows });
|
||||
}
|
||||
|
||||
export async function saveDraft(req, res, session) {
|
||||
const body = req.body || {};
|
||||
const content = String(body.content || "").slice(0, 5000);
|
||||
|
||||
if (body.id) {
|
||||
const existing = db.prepare(`SELECT * FROM application_drafts WHERE id = ?`).get(Number(body.id));
|
||||
if (!existing) return json(res, { ok: false, error: "Entwurf nicht gefunden." }, 404);
|
||||
const isOwnDraft = existing.author === session.actor;
|
||||
if (!can(session, isOwnDraft ? "DRAFTS_EDIT_OWN" : "DRAFTS_EDIT_OTHERS")) {
|
||||
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
}
|
||||
db.prepare(`UPDATE application_drafts SET content = ?, updated_at = ? WHERE id = ?`).run(content, nowIso(), Number(body.id));
|
||||
logAction(session.actor, "draft.update", String(body.id), null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
if (!can(session, "DRAFTS_CREATE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
db.prepare(`INSERT INTO application_drafts (application_id, author, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`).run(
|
||||
String(body.applicationId || ""),
|
||||
session.actor,
|
||||
content,
|
||||
nowIso(),
|
||||
nowIso()
|
||||
);
|
||||
logAction(session.actor, "draft.create", body.applicationId, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* =====================================================================
|
||||
routes/audit.js — Aktivitätsprotokoll ansehen. 1:1 portiert aus
|
||||
cloudflare-worker/src/routes/audit.js.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import { json } from "../lib/http.js";
|
||||
|
||||
export async function listAuditLog(req, res, session) {
|
||||
if (!session) return json(res, { ok: false, error: "Nicht angemeldet." }, 401);
|
||||
if (!session.isOwner && !hasPermission(session.permissions, session.overrides, "SETTINGS_VIEW_ACTIVITY_LOG")) {
|
||||
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
}
|
||||
const body = req.body || {};
|
||||
const limit = Math.min(Number(body.limit) || 100, 500);
|
||||
const rows = db
|
||||
.prepare(`SELECT id, actor, action, target, detail, created_at FROM audit_log ORDER BY created_at DESC LIMIT ?`)
|
||||
.all(limit);
|
||||
return json(res, { ok: true, entries: rows });
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/* =====================================================================
|
||||
routes/auth.js — Login / Logout (interner Team-Bereich).
|
||||
1:1 portiert aus cloudflare-worker/src/routes/auth.js.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { verifyCode, createSession, endSession, isLockedOut, recordFailedAttempt, clearAttempts, clientKey } from "../lib/auth.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { nowIso, sha256Hex } from "../lib/crypto.js";
|
||||
import { json } from "../lib/http.js";
|
||||
|
||||
/**
|
||||
* Der Hauptadministrator ändert seinen eigenen Zugangscode.
|
||||
* Nur mit gültiger Owner-Session UND erneuter Eingabe des aktuellen Codes.
|
||||
* Gespeichert wird ausschließlich der SHA-256-Hash — nie der Klartext.
|
||||
*/
|
||||
export async function setOwnerCode(req, res, session) {
|
||||
if (!session || !session.isOwner) {
|
||||
return json(res, { ok: false, error: "Das darf nur der Hauptadministrator." }, 403);
|
||||
}
|
||||
|
||||
const body = req.body || {};
|
||||
const currentCode = String(body.currentCode || "");
|
||||
const newCode = String(body.newCode || "");
|
||||
|
||||
const check = await verifyCode(currentCode);
|
||||
if (!check.ok || !check.isOwner) {
|
||||
logAction("owner", "owner.code_change_failed", null, null);
|
||||
return json(res, { ok: false, error: "Der aktuelle Code stimmt nicht." }, 403);
|
||||
}
|
||||
|
||||
if (newCode.length < 10) {
|
||||
return json(res, { ok: false, error: "Der neue Code muss mindestens 10 Zeichen haben." }, 400);
|
||||
}
|
||||
if (newCode === currentCode) {
|
||||
return json(res, { ok: false, error: "Der neue Code muss sich vom alten unterscheiden." }, 400);
|
||||
}
|
||||
|
||||
const hash = await sha256Hex(newCode);
|
||||
db.prepare(
|
||||
`INSERT INTO app_settings (key, value, updated_at) VALUES ('owner_code_hash', ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
||||
).run(hash, nowIso());
|
||||
|
||||
logAction("owner", "owner.code_changed", null, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function login(req, res) {
|
||||
const body = req.body || {};
|
||||
const code = String(body.code || "");
|
||||
const key = `login:${clientKey(req)}`;
|
||||
|
||||
if (isLockedOut(key)) {
|
||||
return json(res, { ok: false, error: "Zu viele Fehlversuche. Bitte in ein paar Minuten erneut versuchen." }, 429);
|
||||
}
|
||||
|
||||
const result = await verifyCode(code);
|
||||
if (!result.ok) {
|
||||
const { count } = recordFailedAttempt(key);
|
||||
logAction("anonym", "login.failed", null, { attempt: count });
|
||||
if (result.reason === "locked") return json(res, { ok: false, error: "Dieser Zugang ist gesperrt." }, 403);
|
||||
if (result.reason === "expired") return json(res, { ok: false, error: "Dieser Zugang ist abgelaufen." }, 403);
|
||||
return json(res, { ok: false, error: "Falscher Code." }, 401);
|
||||
}
|
||||
|
||||
clearAttempts(key);
|
||||
|
||||
if (result.isOwner) {
|
||||
const token = await createSession({ isOwner: true });
|
||||
logAction("owner", "login.success", null, null);
|
||||
return json(res, { ok: true, token, isOwner: true, name: "Dogi", roleName: "Hauptadministrator" });
|
||||
}
|
||||
|
||||
const token = await createSession({ isOwner: false, userId: result.user.id });
|
||||
db.prepare(`UPDATE users SET last_login_at = ? WHERE id = ?`).run(nowIso(), result.user.id);
|
||||
|
||||
const roleRow = result.user.role_id
|
||||
? db.prepare(`SELECT name, permissions FROM roles WHERE id = ?`).get(result.user.role_id)
|
||||
: null;
|
||||
const overridesRows = db.prepare(`SELECT permission, allowed FROM user_permission_overrides WHERE user_id = ?`).all(result.user.id);
|
||||
const overrides = {};
|
||||
for (const r of overridesRows) overrides[r.permission] = !!r.allowed;
|
||||
const permissions = roleRow ? JSON.parse(roleRow.permissions || "[]") : [];
|
||||
|
||||
logAction(result.user.username, "login.success", null, null);
|
||||
|
||||
return json(res, {
|
||||
ok: true,
|
||||
token,
|
||||
isOwner: false,
|
||||
name: result.user.name,
|
||||
username: result.user.username,
|
||||
roleName: roleRow?.name || "Ohne Rolle",
|
||||
permissions,
|
||||
overrides,
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout(req, res) {
|
||||
const auth = req.headers["authorization"] || "";
|
||||
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
|
||||
await endSession(token);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/* =====================================================================
|
||||
routes/roles.js — Rollen erstellen/umbenennen/verwalten. 1:1 portiert
|
||||
aus cloudflare-worker/src/routes/roles.js.
|
||||
|
||||
Die Owner-Rolle existiert absichtlich NICHT in dieser Tabelle — sie ist
|
||||
fest an POSTFACH_CODE gebunden und kann hier weder erstellt, verändert
|
||||
noch gelöscht werden (siehe Anforderungsdokument: "Meine persönliche
|
||||
Hauptadministrator-Rolle bleibt davon ausgeschlossen").
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { generateId, nowIso } from "../lib/crypto.js";
|
||||
import { isValidPermissionKey, hasPermission } from "../lib/permissions.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { json } from "../lib/http.js";
|
||||
|
||||
function canManageRoles(session) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return (
|
||||
hasPermission(session.permissions, session.overrides, "TEAM_CREATE_ROLE") ||
|
||||
hasPermission(session.permissions, session.overrides, "TEAM_EDIT_PERMISSIONS")
|
||||
);
|
||||
}
|
||||
|
||||
function canViewRoles(session) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return (
|
||||
canManageRoles(session) ||
|
||||
hasPermission(session.permissions, session.overrides, "TEAM_ASSIGN_ROLE") ||
|
||||
hasPermission(session.permissions, session.overrides, "TEAM_VIEW")
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizePermissions(list) {
|
||||
if (!Array.isArray(list)) return [];
|
||||
return [...new Set(list.filter((k) => typeof k === "string" && isValidPermissionKey(k)))];
|
||||
}
|
||||
|
||||
export async function listRoles(req, res, session) {
|
||||
if (!canViewRoles(session)) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const rows = db.prepare(`SELECT * FROM roles ORDER BY created_at ASC`).all();
|
||||
const roles = rows.map((r) => ({ ...r, permissions: JSON.parse(r.permissions || "[]") }));
|
||||
return json(res, { ok: true, roles });
|
||||
}
|
||||
|
||||
export async function createRole(req, res, session) {
|
||||
if (!canManageRoles(session)) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const name = String(body.name || "").trim().slice(0, 60);
|
||||
if (!name) return json(res, { ok: false, error: "Name fehlt." }, 400);
|
||||
const permissions = sanitizePermissions(body.permissions);
|
||||
const id = generateId();
|
||||
|
||||
db.prepare(`INSERT INTO roles (id, name, permissions, created_at) VALUES (?, ?, ?, ?)`).run(id, name, JSON.stringify(permissions), nowIso());
|
||||
|
||||
logAction(session.actor, "role.create", id, { name, permissions });
|
||||
return json(res, { ok: true, role: { id, name, permissions } });
|
||||
}
|
||||
|
||||
export async function updateRole(req, res, session) {
|
||||
if (!canManageRoles(session)) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
if (!id) return json(res, { ok: false, error: "id fehlt." }, 400);
|
||||
|
||||
const existing = db.prepare(`SELECT * FROM roles WHERE id = ?`).get(id);
|
||||
if (!existing) return json(res, { ok: false, error: "Rolle nicht gefunden." }, 404);
|
||||
|
||||
const name = body.name !== undefined ? String(body.name).trim().slice(0, 60) || existing.name : existing.name;
|
||||
const permissions = body.permissions !== undefined ? sanitizePermissions(body.permissions) : JSON.parse(existing.permissions || "[]");
|
||||
|
||||
db.prepare(`UPDATE roles SET name = ?, permissions = ? WHERE id = ?`).run(name, JSON.stringify(permissions), id);
|
||||
|
||||
logAction(session.actor, "role.update", id, { name, permissions });
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function deleteRole(req, res, session) {
|
||||
if (!canManageRoles(session)) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
if (!id) return json(res, { ok: false, error: "id fehlt." }, 400);
|
||||
|
||||
const inUse = db.prepare(`SELECT COUNT(*) as n FROM users WHERE role_id = ?`).get(id);
|
||||
if (inUse && inUse.n > 0) {
|
||||
return json(res, { ok: false, error: "Rolle ist noch Personen zugewiesen — erst umziehen." }, 400);
|
||||
}
|
||||
|
||||
db.prepare(`DELETE FROM roles WHERE id = ?`).run(id);
|
||||
logAction(session.actor, "role.delete", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/* =====================================================================
|
||||
routes/supporter-admin.js — Administrationsbereich für das Supporter-
|
||||
Abo (Dogfather_VanVan_Supporter_Abo.odt Abschnitt 27). 1:1 portiert aus
|
||||
cloudflare-worker/src/routes/supporter-admin.js.
|
||||
|
||||
Nutzt die BESTEHENDE interne Team-Session (auth.js/requireSession),
|
||||
NICHT die öffentliche Supporter-Session — nur berechtigte
|
||||
Administrator:innen (Owner oder Rolle mit SUPPORTER_*-Rechten) dürfen
|
||||
hierauf zugreifen.
|
||||
===================================================================== */
|
||||
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 { computeProgress, timelineForCurrentCycle } from "../lib/supporter-cycle.js";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** Abonnentenübersicht mit Filtern (Abschnitt 27). */
|
||||
export async function listSupporters(req, res, session) {
|
||||
if (!can(session, "SUPPORTER_VIEW")) return forbidden(res);
|
||||
const body = req.body || {};
|
||||
const filter = body.filter || "alle";
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT u.id, u.display_name, u.tiktok_username, u.email, u.email_verified, u.created_at,
|
||||
s.id as sub_id, s.paypal_subscription_id, s.status as sub_status, s.started_at,
|
||||
s.next_payment_at, s.paid_period_end_at, s.cancelled_at, s.total_paid_months
|
||||
FROM supporter_users u
|
||||
LEFT JOIN supporter_subscriptions s ON s.supporter_id = u.id
|
||||
WHERE u.status = 'active'
|
||||
ORDER BY u.created_at DESC`
|
||||
)
|
||||
.all();
|
||||
|
||||
let list = rows;
|
||||
const FILTER_MAP = {
|
||||
aktiv: (r) => r.sub_status === "active",
|
||||
gekuendigt: (r) => r.sub_status === "cancelled",
|
||||
zahlung_fehlgeschlagen: (r) => r.sub_status === "suspended",
|
||||
monat_6: (r) => r.total_paid_months >= 6,
|
||||
monat_12: (r) => r.total_paid_months >= 12,
|
||||
monat_18: (r) => r.total_paid_months >= 18,
|
||||
monat_24: (r) => r.total_paid_months >= 24,
|
||||
monat_30: (r) => r.total_paid_months >= 30,
|
||||
};
|
||||
if (FILTER_MAP[filter]) list = list.filter(FILTER_MAP[filter]);
|
||||
|
||||
// Fortschritt (Zyklus/Meilenstein) rein rechnerisch aus total_paid_months
|
||||
// ergänzen (Nutzer-Wunsch 04.08.2026: "wo die Leute mit den Abos dran
|
||||
// sind ... alles einsehen können") — dieselbe Logik wie im Supporter-
|
||||
// Bereich selbst (lib/supporter-cycle.js), damit nichts auseinanderläuft.
|
||||
list = list.map((r) => ({ ...r, progress: computeProgress(r.total_paid_months || 0) }));
|
||||
|
||||
return json(res, { ok: true, supporters: list });
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersicht über ALLE Supporter hinweg (Nutzer-Wunsch 04.08.2026: "ein
|
||||
* festes Fenster, wo man drauf klicken muss um zu sehen wer was hat, wie
|
||||
* weit ist" — statt sich erst durch jeden einzelnen Supporter klicken zu
|
||||
* müssen). Liefert die komplette Prämien-Warteschlange (für den Versand)
|
||||
* und die letzten Zahlungen über alle Supporter, jeweils mit Name/E-Mail
|
||||
* dazu — bewusst EIN kombinierter Aufruf statt vieler Einzelabfragen.
|
||||
*/
|
||||
export async function getAdminOverview(req, res, session) {
|
||||
if (!can(session, "SUPPORTER_VIEW")) return forbidden(res);
|
||||
|
||||
const premiums = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as collection_name, u.display_name, u.email, u.tiktok_username
|
||||
FROM supporter_premiums p
|
||||
JOIN supporter_collections c ON c.id = p.collection_id
|
||||
JOIN supporter_users u ON u.id = p.supporter_id
|
||||
ORDER BY
|
||||
CASE p.redeem_status
|
||||
WHEN 'wird_geprueft' THEN 0
|
||||
WHEN 'wird_vorbereitet' THEN 1
|
||||
WHEN 'versandbereit' THEN 2
|
||||
WHEN 'problem' THEN 3
|
||||
WHEN 'ersatz_wird_vorbereitet' THEN 4
|
||||
WHEN 'verfuegbar' THEN 5
|
||||
WHEN 'versendet' THEN 6
|
||||
WHEN 'zugestellt' THEN 7
|
||||
WHEN 'erhalten' THEN 8
|
||||
ELSE 9
|
||||
END,
|
||||
p.unlocked_at DESC`
|
||||
)
|
||||
.all();
|
||||
|
||||
const payments = db
|
||||
.prepare(
|
||||
`SELECT pay.*, u.display_name, u.email
|
||||
FROM supporter_payments pay
|
||||
JOIN supporter_users u ON u.id = pay.supporter_id
|
||||
ORDER BY pay.paid_at DESC LIMIT 100`
|
||||
)
|
||||
.all();
|
||||
|
||||
return json(res, { ok: true, premiums, payments });
|
||||
}
|
||||
|
||||
/** Details + Prämien eines einzelnen Supporters. */
|
||||
export async function getSupporterDetail(req, res, session) {
|
||||
if (!can(session, "SUPPORTER_VIEW")) return forbidden(res);
|
||||
const body = req.body || {};
|
||||
const supporter = db.prepare(`SELECT * FROM supporter_users WHERE id = ?`).get(body.supporterId);
|
||||
if (!supporter) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
|
||||
|
||||
const sub = db
|
||||
.prepare(`SELECT * FROM supporter_subscriptions WHERE supporter_id = ? ORDER BY created_at DESC LIMIT 1`)
|
||||
.get(supporter.id);
|
||||
const premiums = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as collection_name FROM supporter_premiums p
|
||||
JOIN supporter_collections c ON c.id = p.collection_id
|
||||
WHERE p.supporter_id = ? ORDER BY p.cycle_number, p.milestone_month`
|
||||
)
|
||||
.all(supporter.id);
|
||||
const payments = db.prepare(`SELECT * FROM supporter_payments WHERE supporter_id = ? ORDER BY paid_at DESC LIMIT 50`).all(supporter.id);
|
||||
|
||||
const totalPaidMonths = sub?.total_paid_months || 0;
|
||||
const progress = computeProgress(totalPaidMonths);
|
||||
const timeline = timelineForCurrentCycle(totalPaidMonths);
|
||||
|
||||
return json(res, {
|
||||
ok: true, supporter, subscription: sub, premiums, payments,
|
||||
progress, timeline,
|
||||
});
|
||||
}
|
||||
|
||||
/** Prämie/Versand manuell bearbeiten (Abschnitt 27 "Prämienverwaltung"). */
|
||||
export async function updatePremium(req, res, session) {
|
||||
if (!can(session, "SUPPORTER_MANAGE_PREMIUMS")) return forbidden(res);
|
||||
const body = req.body || {};
|
||||
const premium = db.prepare(`SELECT * FROM supporter_premiums WHERE id = ?`).get(body.premiumId);
|
||||
if (!premium) return json(res, { ok: false, error: "Prämie nicht gefunden." }, 404);
|
||||
|
||||
const ALLOWED_STATUS = [
|
||||
"verfuegbar", "eingeloest", "wird_geprueft", "wird_vorbereitet",
|
||||
"versandbereit", "versendet", "zugestellt", "erhalten", "problem", "ersatz_wird_vorbereitet",
|
||||
];
|
||||
const nextStatus = ALLOWED_STATUS.includes(body.redeemStatus) ? body.redeemStatus : premium.redeem_status;
|
||||
const trackingNumber = body.trackingNumber !== undefined ? body.trackingNumber : premium.tracking_number;
|
||||
const shippedAt = nextStatus === "versendet" && !premium.shipped_at ? nowIso() : premium.shipped_at;
|
||||
const deliveredAt = nextStatus === "zugestellt" && !premium.delivered_at ? nowIso() : premium.delivered_at;
|
||||
|
||||
db.prepare(
|
||||
`UPDATE supporter_premiums SET redeem_status = ?, tracking_number = ?, shipped_at = ?, delivered_at = ?, admin_note = ? WHERE id = ?`
|
||||
).run(nextStatus, trackingNumber || null, shippedAt, deliveredAt, body.adminNote ?? premium.admin_note, premium.id);
|
||||
|
||||
logAction(session.actor, "supporter.premium_updated", premium.id, {
|
||||
from: premium.redeem_status, to: nextStatus,
|
||||
});
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/** Kollektionen anlegen/bearbeiten (Abschnitt 27 "Kollektionen verwalten"). */
|
||||
export async function listCollections(req, res, session) {
|
||||
if (!can(session, "SUPPORTER_VIEW")) return forbidden(res);
|
||||
const rows = db.prepare(`SELECT * FROM supporter_collections ORDER BY collection_number ASC`).all();
|
||||
return json(res, { ok: true, collections: rows });
|
||||
}
|
||||
|
||||
export async function upsertCollection(req, res, session) {
|
||||
if (!can(session, "SUPPORTER_MANAGE_COLLECTIONS")) return forbidden(res);
|
||||
const body = req.body || {};
|
||||
const num = Number(body.collectionNumber);
|
||||
if (!Number.isFinite(num) || num < 1) return json(res, { ok: false, error: "Ungültige Kollektionsnummer." }, 400);
|
||||
|
||||
const existing = db.prepare(`SELECT id FROM supporter_collections WHERE collection_number = ?`).get(num);
|
||||
const id = existing?.id || generateId();
|
||||
const fields = {
|
||||
name: body.name || `Kollektion ${String(num).padStart(2, "0")}`,
|
||||
theme: body.theme || null,
|
||||
description: body.description || null,
|
||||
cover_image: body.coverImage || null,
|
||||
small_teddy_design: body.smallTeddyDesign || null,
|
||||
medium_teddy_design: body.mediumTeddyDesign || null,
|
||||
large_teddy_design: body.largeTeddyDesign || null,
|
||||
mug_design: body.mugDesign || null,
|
||||
card_design: body.cardDesign || null,
|
||||
hoodie_design: body.hoodieDesign || null,
|
||||
available_hoodie_sizes: JSON.stringify(body.availableHoodieSizes || ["S", "M", "L", "XL", "XXL"]),
|
||||
status: body.status === "published" ? "published" : "draft",
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
`UPDATE supporter_collections SET name=?, theme=?, description=?, cover_image=?, small_teddy_design=?,
|
||||
medium_teddy_design=?, large_teddy_design=?, mug_design=?, card_design=?, hoodie_design=?,
|
||||
available_hoodie_sizes=?, status=? WHERE id=?`
|
||||
).run(
|
||||
fields.name, fields.theme, fields.description, fields.cover_image, fields.small_teddy_design,
|
||||
fields.medium_teddy_design, fields.large_teddy_design, fields.mug_design, fields.card_design,
|
||||
fields.hoodie_design, fields.available_hoodie_sizes, fields.status, id
|
||||
);
|
||||
} else {
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_collections
|
||||
(id, collection_number, name, theme, description, cover_image, small_teddy_design, medium_teddy_design,
|
||||
large_teddy_design, mug_design, card_design, hoodie_design, available_hoodie_sizes, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id, num, fields.name, fields.theme, fields.description, fields.cover_image, fields.small_teddy_design,
|
||||
fields.medium_teddy_design, fields.large_teddy_design, fields.mug_design, fields.card_design,
|
||||
fields.hoodie_design, fields.available_hoodie_sizes, fields.status, nowIso()
|
||||
);
|
||||
}
|
||||
|
||||
logAction(session.actor, "supporter.collection_saved", id, { collectionNumber: num });
|
||||
return json(res, { ok: true, id });
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/* =====================================================================
|
||||
routes/supporter-paypal.js — PayPal-Abo erstellen + Webhook-Verarbeitung
|
||||
(Dogfather_VanVan_Supporter_Abo.odt Abschnitt 6-8). 1:1 portiert aus
|
||||
cloudflare-worker/src/routes/supporter-paypal.js.
|
||||
|
||||
WICHTIG: `req.rawBody` (der unveränderte Roh-Text des Request-Bodys,
|
||||
von index.js im express.json()-verify-Callback gesetzt) wird für die
|
||||
PayPal-Signaturprüfung gebraucht — req.body ist bereits geparst und
|
||||
könnte beim Neuzusammenbau minimal vom Original abweichen (Reihenfolge/
|
||||
Whitespace), was die Signatur ungültig machen würde.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { generateId, nowIso } from "../lib/crypto.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { createSubscription, verifyWebhookSignature, cancelSubscription, PayPalNotConfiguredError } from "../lib/paypal.js";
|
||||
import { allUnlockedMilestones } from "../lib/supporter-cycle.js";
|
||||
import { sendMilestoneUnlocked } from "../lib/supporter-mail.js";
|
||||
|
||||
const REDEEM_LABELS = {
|
||||
teddy_klein: "Kleiner Hasen-Teddy",
|
||||
teddy_mittel: "Mittelgroßer Hasen-Teddy",
|
||||
teddy_gross: "Großer Hasen-Teddy",
|
||||
tasse_karte: "Tasse & Autogrammkarte",
|
||||
hoodie: "Exklusiver Hoodie",
|
||||
};
|
||||
|
||||
function paypalNotConfiguredResponse(res) {
|
||||
return json(
|
||||
res,
|
||||
{
|
||||
ok: false,
|
||||
code: "PAYPAL_NOT_CONFIGURED",
|
||||
error:
|
||||
"PayPal ist auf dieser Website noch nicht eingerichtet. Der Websitebetreiber muss zuerst " +
|
||||
"PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PAYPAL_PLAN_ID und PAYPAL_WEBHOOK_ID setzen " +
|
||||
"(siehe cloudflare-worker/README.md, Abschnitt 'Supporter-Abo Setup').",
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
/** Startet ein PayPal-Abonnement für den eingeloggten Supporter (Abschnitt 6, Schritt 6). */
|
||||
export async function startPaypalSubscription(req, res, supporter) {
|
||||
const body = req.body || {};
|
||||
const origin = body.origin || "https://dogfather-universe.com";
|
||||
|
||||
try {
|
||||
const { paypalSubscriptionId, approveUrl } = await createSubscription({
|
||||
supporterId: supporter.id,
|
||||
returnUrl: `${origin}/supporter.html?paypal=success`,
|
||||
cancelUrl: `${origin}/abonnieren.html?paypal=cancelled`,
|
||||
});
|
||||
if (!approveUrl) return json(res, { ok: false, error: "PayPal hat keinen Bestätigungslink geliefert." }, 502);
|
||||
|
||||
const subId = generateId();
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_subscriptions
|
||||
(id, supporter_id, paypal_subscription_id, paypal_plan_id, status, total_paid_months, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)`
|
||||
).run(subId, supporter.id, paypalSubscriptionId, process.env.PAYPAL_PLAN_ID, nowIso(), nowIso());
|
||||
|
||||
logAction("system", "supporter.subscription_started", supporter.id, { paypalSubscriptionId });
|
||||
return json(res, { ok: true, approveUrl });
|
||||
} catch (err) {
|
||||
if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse(res);
|
||||
return json(res, { ok: false, error: "PayPal-Abo konnte nicht erstellt werden." }, 502);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gegenstück zu den PayPal Smart-Payment-Buttons im Frontend: dort erzeugt
|
||||
* das PayPal-JS-SDK die Subscription direkt im Browser (zeigt dabei
|
||||
* automatisch alle für die Käuferin/den Käufer verfügbaren Zahlarten an —
|
||||
* PayPal-Guthaben, Bankkonto, Kreditkarte als Gast, je nach Land/Konto),
|
||||
* damit landet das Geld direkt auf Dogis PayPal-Konto. Der Browser
|
||||
* übergibt uns danach nur noch die fertige `subscriptionID`, die wir hier
|
||||
* mit dem Supporter-Konto verknüpfen. Der tatsächliche Aktivierungsstatus
|
||||
* kommt weiterhin ausschließlich über den geprüften Webhook (Abschnitt 6:
|
||||
* "Eine reine Weiterleitung... darf nicht ausreichen, um das Abo als aktiv
|
||||
* zu markieren").
|
||||
*/
|
||||
export async function confirmPaypalSubscription(req, res, supporter) {
|
||||
const body = req.body || {};
|
||||
const paypalSubscriptionId = String(body.subscriptionId || "").trim();
|
||||
if (!paypalSubscriptionId) return json(res, { ok: false, error: "Keine PayPal-Subscription-ID übergeben." }, 400);
|
||||
|
||||
// Rechts-Update 04.08.2026: Nachweis der Widerrufsrecht-Zustimmung
|
||||
// (sofortiger Leistungsbeginn bei digitalen Inhalten, § 356 Abs. 5 BGB /
|
||||
// entspr. Regelung, siehe agb.html Ziffer 6). Die Checkbox im Frontend
|
||||
// (abonnieren.html) blockiert das Laden der PayPal-Buttons bereits
|
||||
// clientseitig — diese serverseitige Prüfung verhindert zusätzlich eine
|
||||
// Umgehung per direktem API-Aufruf ohne echte Zustimmung.
|
||||
const widerrufConsentAt = String(body.widerrufConsentAt || "").trim();
|
||||
const consentDatum = widerrufConsentAt ? new Date(widerrufConsentAt) : null;
|
||||
const consentGueltig = consentDatum && !Number.isNaN(consentDatum.getTime()) && consentDatum.getTime() <= Date.now();
|
||||
if (!consentGueltig) {
|
||||
return json(res, { ok: false, error: "Bitte bestätige zuerst die Checkbox zum sofortigen Leistungsbeginn." }, 400);
|
||||
}
|
||||
|
||||
const existing = db.prepare(`SELECT id FROM supporter_subscriptions WHERE paypal_subscription_id = ?`).get(paypalSubscriptionId);
|
||||
if (existing) return json(res, { ok: true }); // schon verknüpft (z.B. doppelter Klick) — kein Fehler
|
||||
|
||||
const subId = generateId();
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_subscriptions
|
||||
(id, supporter_id, paypal_subscription_id, paypal_plan_id, status, total_paid_months, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)`
|
||||
).run(subId, supporter.id, paypalSubscriptionId, process.env.PAYPAL_PLAN_ID, nowIso(), nowIso());
|
||||
|
||||
logAction("system", "supporter.subscription_confirmed_client_side", supporter.id, {
|
||||
paypalSubscriptionId,
|
||||
widerrufConsentAt: consentDatum.toISOString(),
|
||||
});
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function cancelPaypalSubscription(req, res, supporter) {
|
||||
const sub = db
|
||||
.prepare(
|
||||
`SELECT * FROM supporter_subscriptions WHERE supporter_id = ? AND status IN ('active','pending','suspended') ORDER BY created_at DESC LIMIT 1`
|
||||
)
|
||||
.get(supporter.id);
|
||||
if (!sub) return json(res, { ok: false, error: "Kein aktives Abonnement gefunden." }, 404);
|
||||
|
||||
try {
|
||||
if (sub.paypal_subscription_id) await cancelSubscription(sub.paypal_subscription_id);
|
||||
} catch (err) {
|
||||
if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse(res);
|
||||
return json(res, { ok: false, error: "Kündigung bei PayPal fehlgeschlagen." }, 502);
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE supporter_subscriptions SET status = 'cancelled', cancelled_at = ?, updated_at = ? WHERE id = ?`).run(
|
||||
nowIso(),
|
||||
nowIso(),
|
||||
sub.id
|
||||
);
|
||||
logAction("system", "supporter.subscription_cancelled", supporter.id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/* ---------- Webhook (öffentlich erreichbar, aber signaturgeprüft) ---------- */
|
||||
|
||||
function findSubscriptionByPaypalId(paypalSubscriptionId) {
|
||||
if (!paypalSubscriptionId) return null;
|
||||
return db.prepare(`SELECT * FROM supporter_subscriptions WHERE paypal_subscription_id = ?`).get(paypalSubscriptionId);
|
||||
}
|
||||
|
||||
function alreadyProcessed(eventId) {
|
||||
if (!eventId) return false;
|
||||
const row = db.prepare(`SELECT id FROM supporter_payments WHERE paypal_event_id = ?`).get(eventId);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/** Erfolgreiche Zahlung: Monat anrechnen, ggf. neue Prämien freischalten (Abschnitt 8, 11-13). */
|
||||
async function handleSuccessfulPayment(sub, event, resource) {
|
||||
const isSandbox = process.env.PAYPAL_ENV !== "live";
|
||||
const eventId = event.id;
|
||||
const paymentId = generateId();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_payments
|
||||
(id, supporter_id, subscription_id, paypal_transaction_id, paypal_event_id, amount, currency,
|
||||
payment_status, is_sandbox, counted_for_progress, paid_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'completed', ?, ?, ?, ?)`
|
||||
).run(
|
||||
paymentId, sub.supporter_id, sub.id, resource.id || null, eventId,
|
||||
resource.amount?.total || resource.amount?.value || null,
|
||||
resource.amount?.currency || "EUR",
|
||||
isSandbox ? 1 : 0, isSandbox ? 0 : 1, nowIso(), nowIso()
|
||||
);
|
||||
|
||||
if (isSandbox) return; // Testzahlungen zählen NIE als echter Monat (Abschnitt 8/34).
|
||||
|
||||
const oldTotal = sub.total_paid_months;
|
||||
const newTotal = oldTotal + 1;
|
||||
db.prepare(`UPDATE supporter_subscriptions SET total_paid_months = ?, updated_at = ? WHERE id = ?`).run(newTotal, nowIso(), sub.id);
|
||||
|
||||
const before = new Set(allUnlockedMilestones(oldTotal).map((m) => `${m.cycleNumber}:${m.month}`));
|
||||
const after = allUnlockedMilestones(newTotal);
|
||||
const newlyUnlocked = after.filter((m) => !before.has(`${m.cycleNumber}:${m.month}`));
|
||||
|
||||
for (const m of newlyUnlocked) {
|
||||
// Kollektion für diesen Zyklus ermitteln (per collection_number = cycleNumber),
|
||||
// fällt auf Kollektion 1 zurück, falls eine spätere Kollektion im Admin-
|
||||
// Bereich noch nicht angelegt wurde — damit niemand ins Leere freigeschaltet wird.
|
||||
const collection =
|
||||
db.prepare(`SELECT id FROM supporter_collections WHERE collection_number = ?`).get(m.cycleNumber) ||
|
||||
db.prepare(`SELECT id FROM supporter_collections WHERE collection_number = 1`).get();
|
||||
if (!collection) continue;
|
||||
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO supporter_premiums
|
||||
(id, supporter_id, collection_id, cycle_number, milestone_month, premium_type, unlocked_at, redeem_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'verfuegbar')`
|
||||
).run(generateId(), sub.supporter_id, collection.id, m.cycleNumber, m.month, m.type, nowIso());
|
||||
|
||||
const supporterRow = db.prepare(`SELECT display_name, email FROM supporter_users WHERE id = ?`).get(sub.supporter_id);
|
||||
if (supporterRow) {
|
||||
await sendMilestoneUnlocked({
|
||||
to: supporterRow.email,
|
||||
name: supporterRow.display_name,
|
||||
premiumLabel: REDEEM_LABELS[m.type] || m.type,
|
||||
}).catch(() => {}); // E-Mail-Fehler dürfen die Webhook-Verarbeitung nie blockieren.
|
||||
}
|
||||
logAction("system", "supporter.milestone_unlocked", sub.supporter_id, { cycle: m.cycleNumber, month: m.month, type: m.type });
|
||||
}
|
||||
}
|
||||
|
||||
function handleReversedPayment(sub, event, resource) {
|
||||
const eventId = event.id;
|
||||
const paymentId = generateId();
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_payments
|
||||
(id, supporter_id, subscription_id, paypal_transaction_id, paypal_event_id, amount, currency,
|
||||
payment_status, is_sandbox, counted_for_progress, paid_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`
|
||||
).run(
|
||||
paymentId, sub.supporter_id, sub.id, resource.id || null, eventId,
|
||||
resource.amount?.total || resource.amount?.value || null, resource.amount?.currency || "EUR",
|
||||
event.event_type.includes("REFUND") ? "refunded" : "reversed",
|
||||
process.env.PAYPAL_ENV !== "live" ? 1 : 0, nowIso(), nowIso()
|
||||
);
|
||||
|
||||
// Zurückgebuchte/zurückerstattete Zahlung: betroffenen Monat zur manuellen
|
||||
// Prüfung markieren statt automatisch zu löschen (Abschnitt 24 — "muss
|
||||
// manuell durch einen Administrator geprüft werden").
|
||||
logAction("admin", "supporter.payment_flagged_for_review", sub.supporter_id, {
|
||||
eventType: event.event_type,
|
||||
transactionId: resource.id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handlePaypalWebhook(req, res) {
|
||||
const rawBody = req.rawBody || JSON.stringify(req.body || {});
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return json(res, { ok: false, error: "Ungültiger Webhook-Body." }, 400);
|
||||
}
|
||||
|
||||
let verified = false;
|
||||
try {
|
||||
verified = await verifyWebhookSignature(req, rawBody);
|
||||
} catch (err) {
|
||||
if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse(res);
|
||||
return json(res, { ok: false, error: "Webhook-Signaturprüfung fehlgeschlagen." }, 400);
|
||||
}
|
||||
if (!verified) return json(res, { ok: false, error: "Ungültige Webhook-Signatur." }, 400);
|
||||
|
||||
if (alreadyProcessed(event.id)) return json(res, { ok: true, duplicate: true });
|
||||
|
||||
const resource = event.resource || {};
|
||||
const type = event.event_type;
|
||||
|
||||
try {
|
||||
if (type === "BILLING.SUBSCRIPTION.ACTIVATED") {
|
||||
const sub = findSubscriptionByPaypalId(resource.id);
|
||||
if (sub) {
|
||||
db.prepare(
|
||||
`UPDATE supporter_subscriptions SET status = 'active', started_at = COALESCE(started_at, ?),
|
||||
next_payment_at = ?, updated_at = ? WHERE id = ?`
|
||||
).run(nowIso(), resource.billing_info?.next_billing_time || null, nowIso(), sub.id);
|
||||
}
|
||||
} else if (type === "BILLING.SUBSCRIPTION.UPDATED") {
|
||||
const sub = findSubscriptionByPaypalId(resource.id);
|
||||
if (sub) {
|
||||
db.prepare(`UPDATE supporter_subscriptions SET next_payment_at = ?, updated_at = ? WHERE id = ?`).run(
|
||||
resource.billing_info?.next_billing_time || sub.next_payment_at,
|
||||
nowIso(),
|
||||
sub.id
|
||||
);
|
||||
}
|
||||
} else if (type === "BILLING.SUBSCRIPTION.CANCELLED") {
|
||||
const sub = findSubscriptionByPaypalId(resource.id);
|
||||
if (sub) {
|
||||
db.prepare(
|
||||
`UPDATE supporter_subscriptions SET status = 'cancelled', cancelled_at = ?,
|
||||
paid_period_end_at = COALESCE(paid_period_end_at, next_payment_at), updated_at = ? WHERE id = ?`
|
||||
).run(nowIso(), nowIso(), sub.id);
|
||||
}
|
||||
} else if (type === "BILLING.SUBSCRIPTION.SUSPENDED") {
|
||||
const sub = findSubscriptionByPaypalId(resource.id);
|
||||
if (sub) db.prepare(`UPDATE supporter_subscriptions SET status = 'suspended', updated_at = ? WHERE id = ?`).run(nowIso(), sub.id);
|
||||
} else if (type === "BILLING.SUBSCRIPTION.EXPIRED") {
|
||||
const sub = findSubscriptionByPaypalId(resource.id);
|
||||
if (sub) db.prepare(`UPDATE supporter_subscriptions SET status = 'expired', updated_at = ? WHERE id = ?`).run(nowIso(), sub.id);
|
||||
} else if (type === "PAYMENT.SALE.COMPLETED") {
|
||||
const sub = findSubscriptionByPaypalId(resource.billing_agreement_id);
|
||||
if (sub) await handleSuccessfulPayment(sub, event, resource);
|
||||
} else if (type === "PAYMENT.SALE.DENIED") {
|
||||
const sub = findSubscriptionByPaypalId(resource.billing_agreement_id);
|
||||
if (sub) logAction("system", "supporter.payment_failed", sub.supporter_id, { transactionId: resource.id });
|
||||
} else if (type === "PAYMENT.SALE.REFUNDED" || type === "PAYMENT.SALE.REVERSED") {
|
||||
const sub = findSubscriptionByPaypalId(resource.billing_agreement_id);
|
||||
if (sub) handleReversedPayment(sub, event, resource);
|
||||
}
|
||||
// Unbekannte/nicht relevante Ereignistypen werden bewusst stillschweigend bestätigt
|
||||
// (PayPal wiederholt Zustellversuche sonst unnötig).
|
||||
} catch (err) {
|
||||
// Bewusst 200 statt 500 an PayPal zurückgeben nur, wenn der Fehler NICHT
|
||||
// die Zahlungsverarbeitung selbst betrifft — hier lassen wir echte
|
||||
// Fehler durchschlagen, damit PayPal es später erneut zustellt.
|
||||
return json(res, { ok: false, error: "Webhook-Verarbeitung fehlgeschlagen." }, 500);
|
||||
}
|
||||
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/* =====================================================================
|
||||
routes/supporter.js — Öffentlicher Supporter-Bereich: Registrierung,
|
||||
E-Mail-Bestätigung, Magic-Link-Login, persönliches Dashboard, Prämien-
|
||||
Einlösung, Aboverwaltung. 1:1 portiert aus
|
||||
cloudflare-worker/src/routes/supporter.js.
|
||||
|
||||
Setzt Dogfather_VanVan_Supporter_Abo.odt Abschnitt 5, 6, 9-18, 21-25 um.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { generateId, nowIso } from "../lib/crypto.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import {
|
||||
normalizeEmail, normalizeTikTokUsername, issueAuthCode, verifyAuthCode,
|
||||
createSupporterSession, endSupporterSession,
|
||||
} from "../lib/supporter-auth.js";
|
||||
import { EmailNotConfiguredError, sendVerifyEmailCode, sendLoginCode } from "../lib/supporter-mail.js";
|
||||
import { computeProgress, timelineForCurrentCycle } from "../lib/supporter-cycle.js";
|
||||
import { verifyGoogleIdToken, GoogleAuthNotConfiguredError } from "../lib/google-auth.js";
|
||||
|
||||
function bad(res, msg, status = 400) {
|
||||
return json(res, { ok: false, error: msg }, status);
|
||||
}
|
||||
|
||||
function emailErrorResponse(res, err) {
|
||||
if (err instanceof EmailNotConfiguredError || err.code === "EMAIL_NOT_CONFIGURED") {
|
||||
return json(
|
||||
res,
|
||||
{
|
||||
ok: false,
|
||||
error:
|
||||
"Der E-Mail-Versand ist auf dieser Website noch nicht eingerichtet (RESEND_API_KEY fehlt). " +
|
||||
"Bitte zuerst in der .env konfigurieren, siehe cloudflare-worker/README.md.",
|
||||
code: "EMAIL_NOT_CONFIGURED",
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
return json(res, { ok: false, error: "E-Mail konnte nicht verschickt werden." }, 502);
|
||||
}
|
||||
|
||||
/* ---------- Registrierung & Login (öffentlich, keine Session) ---------- */
|
||||
|
||||
export async function registerSupporter(req, res) {
|
||||
const body = req.body || {};
|
||||
const displayName = String(body.displayName || "").trim().slice(0, 60);
|
||||
const tiktokUsername = normalizeTikTokUsername(body.tiktokUsername);
|
||||
const email = normalizeEmail(body.email);
|
||||
|
||||
if (!displayName) return bad(res, "Bitte gib einen Namen oder Anzeigenamen an.");
|
||||
if (!email || !email.includes("@")) return bad(res, "Bitte gib eine gültige E-Mail-Adresse an.");
|
||||
|
||||
const existing = db.prepare(`SELECT id, email_verified FROM supporter_users WHERE email = ?`).get(email);
|
||||
|
||||
let supporterId;
|
||||
if (existing) {
|
||||
if (existing.email_verified) {
|
||||
return bad(res, "Für diese E-Mail-Adresse existiert bereits ein Supporter-Konto. Bitte einloggen.", 409);
|
||||
}
|
||||
supporterId = existing.id;
|
||||
db.prepare(`UPDATE supporter_users SET display_name = ?, tiktok_username = ? WHERE id = ?`).run(displayName, tiktokUsername, supporterId);
|
||||
} else {
|
||||
supporterId = generateId();
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_users (id, display_name, tiktok_username, email, email_verified, created_at)
|
||||
VALUES (?, ?, ?, ?, 0, ?)`
|
||||
).run(supporterId, displayName, tiktokUsername, email, nowIso());
|
||||
logAction("system", "supporter.registered", supporterId, { email });
|
||||
}
|
||||
|
||||
const code = await issueAuthCode(supporterId, "verify_email");
|
||||
try {
|
||||
await sendVerifyEmailCode({ to: email, name: displayName, code });
|
||||
} catch (err) {
|
||||
return emailErrorResponse(res, err);
|
||||
}
|
||||
return json(res, { ok: true, needsVerification: true });
|
||||
}
|
||||
|
||||
export async function verifySupporterEmail(req, res) {
|
||||
const body = req.body || {};
|
||||
const email = normalizeEmail(body.email);
|
||||
const code = String(body.code || "").trim();
|
||||
if (!email || !code) return bad(res, "E-Mail und Code werden benötigt.");
|
||||
|
||||
const supporter = db.prepare(`SELECT id, display_name FROM supporter_users WHERE email = ?`).get(email);
|
||||
if (!supporter) return bad(res, "Kein Supporter-Konto mit dieser E-Mail-Adresse gefunden.", 404);
|
||||
|
||||
const valid = await verifyAuthCode(supporter.id, "verify_email", code);
|
||||
if (!valid) return bad(res, "Der Code ist ungültig oder abgelaufen.", 401);
|
||||
|
||||
db.prepare(`UPDATE supporter_users SET email_verified = 1, last_login_at = ? WHERE id = ?`).run(nowIso(), supporter.id);
|
||||
logAction("system", "supporter.email_verified", supporter.id, null);
|
||||
|
||||
const token = await createSupporterSession(supporter.id);
|
||||
return json(res, { ok: true, token });
|
||||
}
|
||||
|
||||
export async function requestSupporterLogin(req, res) {
|
||||
const body = req.body || {};
|
||||
const email = normalizeEmail(body.email);
|
||||
if (!email) return bad(res, "Bitte gib deine E-Mail-Adresse an.");
|
||||
|
||||
const supporter = db
|
||||
.prepare(`SELECT id, display_name, email_verified FROM supporter_users WHERE email = ? AND status = 'active'`)
|
||||
.get(email);
|
||||
// Bewusst IMMER ok:true nach außen (keine Auskunft, ob ein Konto existiert).
|
||||
if (!supporter || !supporter.email_verified) return json(res, { ok: true });
|
||||
|
||||
const code = await issueAuthCode(supporter.id, "login");
|
||||
try {
|
||||
await sendLoginCode({ to: email, name: supporter.display_name, code });
|
||||
} catch (err) {
|
||||
return emailErrorResponse(res, err);
|
||||
}
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function verifySupporterLogin(req, res) {
|
||||
const body = req.body || {};
|
||||
const email = normalizeEmail(body.email);
|
||||
const code = String(body.code || "").trim();
|
||||
if (!email || !code) return bad(res, "E-Mail und Code werden benötigt.");
|
||||
|
||||
const supporter = db.prepare(`SELECT id FROM supporter_users WHERE email = ? AND status = 'active'`).get(email);
|
||||
if (!supporter) return bad(res, "Kein Supporter-Konto gefunden.", 404);
|
||||
|
||||
const valid = await verifyAuthCode(supporter.id, "login", code);
|
||||
if (!valid) return bad(res, "Der Code ist ungültig oder abgelaufen.", 401);
|
||||
|
||||
db.prepare(`UPDATE supporter_users SET last_login_at = ? WHERE id = ?`).run(nowIso(), supporter.id);
|
||||
const token = await createSupporterSession(supporter.id);
|
||||
return json(res, { ok: true, token });
|
||||
}
|
||||
|
||||
/**
|
||||
* "Mit Google anmelden" — deckt Registrierung UND Login gleichzeitig ab:
|
||||
* Google hat die E-Mail-Adresse bereits selbst bestätigt, deshalb entfällt
|
||||
* hier der sonst nötige Bestätigungscode. Existiert schon ein Konto mit
|
||||
* dieser E-Mail (egal ob per Code oder vorher schon per Google angelegt),
|
||||
* wird es einfach eingeloggt statt doppelt erzeugt.
|
||||
*/
|
||||
export async function googleLoginSupporter(req, res) {
|
||||
const body = req.body || {};
|
||||
let result;
|
||||
try {
|
||||
result = await verifyGoogleIdToken(body.credential);
|
||||
} catch (err) {
|
||||
if (err instanceof GoogleAuthNotConfiguredError) {
|
||||
return json(
|
||||
res,
|
||||
{
|
||||
ok: false,
|
||||
code: "GOOGLE_AUTH_NOT_CONFIGURED",
|
||||
error: "Google-Login ist auf dieser Website noch nicht eingerichtet.",
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
return json(res, { ok: false, error: "Google-Anmeldung fehlgeschlagen." }, 502);
|
||||
}
|
||||
if (!result.ok) return bad(res, result.reason || "Google-Anmeldung fehlgeschlagen.", 401);
|
||||
|
||||
let supporter = db.prepare(`SELECT id FROM supporter_users WHERE email = ? AND status = 'active'`).get(result.email);
|
||||
|
||||
if (!supporter) {
|
||||
const id = generateId();
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_users (id, display_name, email, email_verified, created_at, last_login_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?)`
|
||||
).run(id, result.name, result.email, nowIso(), nowIso());
|
||||
supporter = { id };
|
||||
logAction("system", "supporter.registered_via_google", supporter.id, { email: result.email });
|
||||
} else {
|
||||
db.prepare(`UPDATE supporter_users SET last_login_at = ? WHERE id = ?`).run(nowIso(), supporter.id);
|
||||
}
|
||||
|
||||
const token = await createSupporterSession(supporter.id);
|
||||
return json(res, { ok: true, token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentliche, NICHT geheime Konfiguration fürs Frontend (Client-IDs sind
|
||||
* per Design öffentlich — nur die zugehörigen Secrets sind es nicht).
|
||||
* Fehlt ein Wert, bleibt der jeweilige Button im Frontend einfach
|
||||
* ausgeblendet statt kaputt zu sein.
|
||||
*/
|
||||
export async function getPublicConfig(req, res) {
|
||||
return json(res, {
|
||||
ok: true,
|
||||
googleClientId: process.env.GOOGLE_CLIENT_ID || null,
|
||||
paypalClientId: process.env.PAYPAL_CLIENT_ID || null,
|
||||
paypalPlanId: process.env.PAYPAL_PLAN_ID || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function logoutSupporter(req, res) {
|
||||
const auth = req.headers["authorization"] || "";
|
||||
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
|
||||
await endSupporterSession(token);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/* ---------- Geschützter Supporter-Bereich (Session erforderlich) ---------- */
|
||||
|
||||
const REDEEM_LABELS = {
|
||||
teddy_klein: "Kleiner Hasen-Teddy",
|
||||
teddy_mittel: "Mittelgroßer Hasen-Teddy",
|
||||
teddy_gross: "Großer Hasen-Teddy",
|
||||
tasse_karte: "Tasse & Autogrammkarte",
|
||||
hoodie: "Exklusiver Hoodie",
|
||||
};
|
||||
|
||||
/** Baut die vollständige Dashboard-Antwort (Fenster 1+2+Archiv, Abschnitt 10/15). */
|
||||
function buildDashboard(supporter) {
|
||||
const sub = db
|
||||
.prepare(`SELECT * FROM supporter_subscriptions WHERE supporter_id = ? ORDER BY created_at DESC LIMIT 1`)
|
||||
.get(supporter.id);
|
||||
|
||||
const totalPaidMonths = sub?.total_paid_months || 0;
|
||||
const progress = computeProgress(totalPaidMonths);
|
||||
const timeline = timelineForCurrentCycle(totalPaidMonths);
|
||||
|
||||
const premiumsRows = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as collection_name, c.collection_number
|
||||
FROM supporter_premiums p JOIN supporter_collections c ON c.id = p.collection_id
|
||||
WHERE p.supporter_id = ? ORDER BY p.cycle_number ASC, p.milestone_month ASC`
|
||||
)
|
||||
.all(supporter.id);
|
||||
const premiums = premiumsRows.map((p) => ({ ...p, label: REDEEM_LABELS[p.premium_type] || p.premium_type }));
|
||||
|
||||
// Archiv: nach Kollektion gruppiert (Abschnitt 15 — abgeschlossene Kollektionen bleiben sichtbar).
|
||||
const byCollection = new Map();
|
||||
for (const p of premiums) {
|
||||
const key = p.collection_number;
|
||||
if (!byCollection.has(key)) byCollection.set(key, { collectionNumber: key, collectionName: p.collection_name, premiums: [] });
|
||||
byCollection.get(key).premiums.push(p);
|
||||
}
|
||||
|
||||
// Zahlungshistorie (Nutzer-Wunsch 04.08.2026: "die Kisten sollen beim
|
||||
// Klicken viel mehr Infos zeigen") — für die Detailansicht "Nächste
|
||||
// Zahlung" im Frontend. sub kann null sein (noch nie ein Abo gehabt).
|
||||
let payments = [];
|
||||
if (sub) {
|
||||
payments = db
|
||||
.prepare(
|
||||
`SELECT amount, currency, payment_status, paid_at, is_sandbox
|
||||
FROM supporter_payments WHERE supporter_id = ? ORDER BY paid_at DESC LIMIT 36`
|
||||
)
|
||||
.all(supporter.id);
|
||||
}
|
||||
|
||||
return {
|
||||
profile: {
|
||||
displayName: supporter.display_name,
|
||||
tiktokUsername: supporter.tiktok_username,
|
||||
email: supporter.email,
|
||||
hasShippingAddress: !!(supporter.ship_street && supporter.ship_zip),
|
||||
},
|
||||
subscription: sub
|
||||
? {
|
||||
status: sub.status,
|
||||
startedAt: sub.started_at,
|
||||
nextPaymentAt: sub.next_payment_at,
|
||||
paidPeriodEndAt: sub.paid_period_end_at,
|
||||
cancelledAt: sub.cancelled_at,
|
||||
monthlyPrice: "4,99 €",
|
||||
totalPaidMonths,
|
||||
}
|
||||
: { status: "none", totalPaidMonths: 0, monthlyPrice: "4,99 €" },
|
||||
progress,
|
||||
timeline,
|
||||
premiums,
|
||||
archive: [...byCollection.values()].sort((a, b) => a.collectionNumber - b.collectionNumber),
|
||||
payments,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSupporterMe(req, res, supporter) {
|
||||
return json(res, { ok: true, ...buildDashboard(supporter) });
|
||||
}
|
||||
|
||||
export async function updateSupporterProfile(req, res, supporter) {
|
||||
const body = req.body || {};
|
||||
const displayName = body.displayName !== undefined ? String(body.displayName).trim().slice(0, 60) : supporter.display_name;
|
||||
const tiktokUsername =
|
||||
body.tiktokUsername !== undefined ? normalizeTikTokUsername(body.tiktokUsername) : supporter.tiktok_username;
|
||||
if (!displayName) return bad(res, "Der Name darf nicht leer sein.");
|
||||
|
||||
db.prepare(`UPDATE supporter_users SET display_name = ?, tiktok_username = ? WHERE id = ?`).run(displayName, tiktokUsername, supporter.id);
|
||||
logAction("system", "supporter.profile_updated", supporter.id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function requestSupporterEmailChange(req, res, supporter) {
|
||||
const body = req.body || {};
|
||||
const newEmail = normalizeEmail(body.newEmail);
|
||||
if (!newEmail || !newEmail.includes("@")) return bad(res, "Bitte gib eine gültige neue E-Mail-Adresse an.");
|
||||
|
||||
const clash = db.prepare(`SELECT id FROM supporter_users WHERE email = ? AND id != ?`).get(newEmail, supporter.id);
|
||||
if (clash) return bad(res, "Diese E-Mail-Adresse wird bereits verwendet.", 409);
|
||||
|
||||
db.prepare(`UPDATE supporter_users SET pending_email = ? WHERE id = ?`).run(newEmail, supporter.id);
|
||||
const code = await issueAuthCode(supporter.id, "change_email");
|
||||
try {
|
||||
await sendVerifyEmailCode({ to: newEmail, name: supporter.display_name, code });
|
||||
} catch (err) {
|
||||
return emailErrorResponse(res, err);
|
||||
}
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function confirmSupporterEmailChange(req, res, supporter) {
|
||||
const body = req.body || {};
|
||||
const code = String(body.code || "").trim();
|
||||
if (!supporter.pending_email) return bad(res, "Es liegt keine E-Mail-Änderung vor.");
|
||||
const valid = await verifyAuthCode(supporter.id, "change_email", code);
|
||||
if (!valid) return bad(res, "Der Code ist ungültig oder abgelaufen.", 401);
|
||||
|
||||
db.prepare(`UPDATE supporter_users SET email = pending_email, pending_email = NULL WHERE id = ?`).run(supporter.id);
|
||||
logAction("system", "supporter.email_changed", supporter.id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/** Prämie einlösen (Abschnitt 17) — Versandadresse erst hier, nicht bei der Registrierung. */
|
||||
export async function redeemPremium(req, res, supporter) {
|
||||
const body = req.body || {};
|
||||
const premium = db.prepare(`SELECT * FROM supporter_premiums WHERE id = ? AND supporter_id = ?`).get(body.premiumId, supporter.id);
|
||||
if (!premium) return bad(res, "Prämie nicht gefunden.", 404);
|
||||
if (premium.redeem_status !== "verfuegbar") return bad(res, "Diese Prämie wurde bereits eingelöst oder ist gesperrt.", 409);
|
||||
|
||||
const useSaved = !!body.useSavedAddress && supporter.ship_street;
|
||||
const addr = useSaved
|
||||
? {
|
||||
name: supporter.ship_name, street: supporter.ship_street, zip: supporter.ship_zip,
|
||||
city: supporter.ship_city, country: supporter.ship_country,
|
||||
}
|
||||
: {
|
||||
name: String(body.shipName || "").trim(),
|
||||
street: String(body.shipStreet || "").trim(),
|
||||
zip: String(body.shipZip || "").trim(),
|
||||
city: String(body.shipCity || "").trim(),
|
||||
country: String(body.shipCountry || "").trim(),
|
||||
};
|
||||
if (!addr.name || !addr.street || !addr.zip || !addr.city || !addr.country) {
|
||||
return bad(res, "Bitte fülle alle Adressfelder aus.");
|
||||
}
|
||||
|
||||
if (premium.premium_type === "hoodie" && !body.hoodieSize) {
|
||||
return bad(res, "Bitte wähle eine Hoodie-Größe.");
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE supporter_premiums SET redeem_status = 'eingeloest', redeemed_at = ?,
|
||||
ship_name = ?, ship_street = ?, ship_zip = ?, ship_city = ?, ship_country = ?,
|
||||
hoodie_size = ?, hoodie_variant = ?
|
||||
WHERE id = ?`
|
||||
).run(nowIso(), addr.name, addr.street, addr.zip, addr.city, addr.country, body.hoodieSize || null, body.hoodieVariant || null, premium.id);
|
||||
|
||||
if (body.saveAddress) {
|
||||
db.prepare(
|
||||
`UPDATE supporter_users SET ship_name=?, ship_street=?, ship_zip=?, ship_city=?, ship_country=?, ship_save_address=1 WHERE id=?`
|
||||
).run(addr.name, addr.street, addr.zip, addr.city, addr.country, supporter.id);
|
||||
}
|
||||
|
||||
logAction("system", "supporter.premium_redeemed", supporter.id, { premiumId: premium.id, type: premium.premium_type });
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function deleteSupporterAccount(req, res, supporter) {
|
||||
// Datenschutz (Abschnitt 30): Konto löschbar. Vertrags-/Zahlungsdaten
|
||||
// bleiben aus gesetzlichen Aufbewahrungspflichten bestehen (nur an den
|
||||
// Nutzerdatensatz selbst wird nicht mehr referenziert) — deshalb hier
|
||||
// bewusst "soft delete" statt hartem DELETE der Zahlungshistorie.
|
||||
db.prepare(`UPDATE supporter_users SET status = 'deleted', email = email || '.geloescht.' || id WHERE id = ?`).run(supporter.id);
|
||||
logAction("system", "supporter.account_deleted", supporter.id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/* =====================================================================
|
||||
routes/users.js — Zugänge und Teammitglieder. 1:1 portiert aus
|
||||
cloudflare-worker/src/routes/users.js.
|
||||
|
||||
Zentrale Regel aus dem Anforderungsdokument, überall durchgesetzt:
|
||||
AUSSCHLIESSLICH der Owner darf Codes sehen, erstellen, ändern,
|
||||
kopieren oder zurücksetzen. Keine Rolle und keine individuelle
|
||||
Berechtigung kann das jemals freischalten — die entsprechenden
|
||||
Funktionen prüfen `session.isOwner` direkt, nie eine Permission.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { generateId, generateFriendlyCode, sha256Hex, encryptCode, decryptCode, nowIso } from "../lib/crypto.js";
|
||||
import { hasPermission, isValidPermissionKey } from "../lib/permissions.js";
|
||||
import { endAllSessionsForUser, verifyCode } from "../lib/auth.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { json } from "../lib/http.js";
|
||||
|
||||
function can(session, key) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return hasPermission(session.permissions, session.overrides, key);
|
||||
}
|
||||
|
||||
function loadUser(id) {
|
||||
return db
|
||||
.prepare(`SELECT u.*, r.name as role_name FROM users u LEFT JOIN roles r ON r.id = u.role_id WHERE u.id = ?`)
|
||||
.get(id);
|
||||
}
|
||||
|
||||
function publicUser(u) {
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
roleId: u.role_id,
|
||||
roleName: u.role_name,
|
||||
status: u.status,
|
||||
note: u.note,
|
||||
expiresAt: u.expires_at,
|
||||
createdAt: u.created_at,
|
||||
createdBy: u.created_by,
|
||||
lastLoginAt: u.last_login_at,
|
||||
// Codes erscheinen hier bewusst NIE — auch nicht verschlüsselt/gehasht.
|
||||
};
|
||||
}
|
||||
|
||||
export async function listUsers(req, res, session) {
|
||||
if (!can(session, "TEAM_VIEW") && !session?.isOwner) {
|
||||
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
}
|
||||
const rows = db
|
||||
.prepare(`SELECT u.*, r.name as role_name FROM users u LEFT JOIN roles r ON r.id = u.role_id ORDER BY u.created_at DESC`)
|
||||
.all();
|
||||
return json(res, { ok: true, users: rows.map(publicUser) });
|
||||
}
|
||||
|
||||
/** Nicht-Owner mit TEAM_PREPARE_USER können einen Entwurf anlegen — noch ohne Code, inaktiv. */
|
||||
export async function prepareUser(req, res, session) {
|
||||
if (!can(session, "TEAM_PREPARE_USER")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const name = String(body.name || "").trim().slice(0, 80);
|
||||
const username = String(body.username || "").trim().toLowerCase().slice(0, 40);
|
||||
if (!name || !username) return json(res, { ok: false, error: "Name und Benutzername sind Pflicht." }, 400);
|
||||
|
||||
const id = generateId();
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, name, username, email, role_id, status, note, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?)`
|
||||
).run(id, name, username, String(body.email || "").trim() || null, body.roleId || null, String(body.note || "").slice(0, 500) || null, nowIso(), session.actor);
|
||||
} catch {
|
||||
return json(res, { ok: false, error: "Benutzername bereits vergeben." }, 400);
|
||||
}
|
||||
|
||||
logAction(session.actor, "user.prepare", id, { username });
|
||||
return json(res, { ok: true, id });
|
||||
}
|
||||
|
||||
/** NUR Owner: legt einen Zugang final an (neu oder aus einem vorbereiteten Entwurf) inkl. Code. */
|
||||
export async function finalizeUser(req, res, session) {
|
||||
if (!session?.isOwner) return json(res, { ok: false, error: "Nur der Hauptadministrator darf Zugänge anlegen/aktivieren." }, 403);
|
||||
const body = req.body || {};
|
||||
|
||||
let id = body.pendingUserId ? String(body.pendingUserId) : null;
|
||||
const name = String(body.name || "").trim().slice(0, 80);
|
||||
const username = String(body.username || "").trim().toLowerCase().slice(0, 40);
|
||||
const email = String(body.email || "").trim() || null;
|
||||
const roleId = body.roleId || null;
|
||||
const note = String(body.note || "").slice(0, 500) || null;
|
||||
|
||||
if (!id && (!name || !username)) {
|
||||
return json(res, { ok: false, error: "Name und Benutzername sind Pflicht." }, 400);
|
||||
}
|
||||
|
||||
const code = body.code ? String(body.code).trim().slice(0, 40) : generateFriendlyCode();
|
||||
const codeHash = await sha256Hex(code);
|
||||
const codeEnc = await encryptCode(code);
|
||||
|
||||
let expiresAt = null;
|
||||
if (body.expiresAt) expiresAt = new Date(body.expiresAt).toISOString();
|
||||
else if (body.expiresInDays) expiresAt = new Date(Date.now() + Number(body.expiresInDays) * 86400000).toISOString();
|
||||
|
||||
if (id) {
|
||||
const existing = loadUser(id);
|
||||
if (!existing) return json(res, { ok: false, error: "Vorbereiteter Benutzer nicht gefunden." }, 404);
|
||||
db.prepare(
|
||||
`UPDATE users SET name = ?, username = ?, email = ?, role_id = ?, code_hash = ?, code_enc = ?,
|
||||
status = 'active', note = ?, expires_at = ? WHERE id = ?`
|
||||
).run(name || existing.name, username || existing.username, email, roleId, codeHash, codeEnc, note, expiresAt, id);
|
||||
} else {
|
||||
id = generateId();
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, name, username, email, role_id, code_hash, code_enc, status, note, expires_at, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)`
|
||||
).run(id, name, username, email, roleId, codeHash, codeEnc, note, expiresAt, nowIso(), session.actor);
|
||||
} catch {
|
||||
return json(res, { ok: false, error: "Benutzername bereits vergeben." }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
logAction(session.actor, "user.finalize", id, { username: username || undefined });
|
||||
// Der Klartext-Code wird JETZT einmalig zurückgegeben — danach nie wieder abrufbar,
|
||||
// außer über den geschützten "Code anzeigen"-Reveal-Flow (siehe revealUserCode()).
|
||||
return json(res, { ok: true, id, code });
|
||||
}
|
||||
|
||||
/** NUR Owner: neuen Code für bestehenden Benutzer setzen (manuell oder automatisch). */
|
||||
export async function setUserCode(req, res, session) {
|
||||
if (!session?.isOwner) return json(res, { ok: false, error: "Nur der Hauptadministrator darf Codes ändern." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
const user = loadUser(id);
|
||||
if (!user) return json(res, { ok: false, error: "Benutzer nicht gefunden." }, 404);
|
||||
|
||||
const code = body.code ? String(body.code).trim().slice(0, 40) : generateFriendlyCode();
|
||||
const codeHash = await sha256Hex(code);
|
||||
const codeEnc = await encryptCode(code);
|
||||
|
||||
db.prepare(`UPDATE users SET code_hash = ?, code_enc = ? WHERE id = ?`).run(codeHash, codeEnc, id);
|
||||
endAllSessionsForUser(id); // alter Code darf auf keinem Gerät mehr gültig bleiben
|
||||
|
||||
logAction(session.actor, "user.code_reset", id, null);
|
||||
return json(res, { ok: true, code });
|
||||
}
|
||||
|
||||
/** NUR Owner, mit erneuter Re-Auth (ownerCode): zeigt einen bestehenden Code im Klartext. */
|
||||
export async function revealUserCode(req, res, session) {
|
||||
if (!session?.isOwner) return json(res, { ok: false, error: "Nur der Hauptadministrator darf Codes ansehen." }, 403);
|
||||
const body = req.body || {};
|
||||
|
||||
const reAuth = await verifyCode(String(body.reAuthCode || ""));
|
||||
if (!reAuth.ok || !reAuth.isOwner) {
|
||||
return json(res, { ok: false, error: "Re-Authentifizierung fehlgeschlagen." }, 401);
|
||||
}
|
||||
|
||||
const id = String(body.id || "");
|
||||
const user = loadUser(id);
|
||||
if (!user || !user.code_enc) return json(res, { ok: false, error: "Kein Code hinterlegt." }, 404);
|
||||
|
||||
const code = await decryptCode(user.code_enc);
|
||||
logAction(session.actor, "user.code_reveal", id, null);
|
||||
return json(res, { ok: true, code });
|
||||
}
|
||||
|
||||
export async function lockUser(req, res, session) {
|
||||
if (!can(session, "TEAM_LOCK_USER")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
db.prepare(`UPDATE users SET status = 'locked' WHERE id = ?`).run(id);
|
||||
endAllSessionsForUser(id);
|
||||
logAction(session.actor, "user.lock", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function unlockUser(req, res, session) {
|
||||
if (!can(session, "TEAM_LOCK_USER")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
const user = loadUser(id);
|
||||
if (!user) return json(res, { ok: false, error: "Benutzer nicht gefunden." }, 404);
|
||||
if (user.expires_at && new Date(user.expires_at) < new Date()) {
|
||||
return json(res, { ok: false, error: "Zugang ist abgelaufen — bitte zuerst Gültigkeit verlängern." }, 400);
|
||||
}
|
||||
db.prepare(`UPDATE users SET status = 'active' WHERE id = ?`).run(id);
|
||||
logAction(session.actor, "user.unlock", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/** NUR Owner: Zugang vollständig löschen. */
|
||||
export async function deleteUser(req, res, session) {
|
||||
if (!session?.isOwner) return json(res, { ok: false, error: "Nur der Hauptadministrator darf Zugänge löschen." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
db.prepare(`DELETE FROM user_permission_overrides WHERE user_id = ?`).run(id);
|
||||
db.prepare(`DELETE FROM sessions WHERE user_id = ?`).run(id);
|
||||
db.prepare(`DELETE FROM users WHERE id = ?`).run(id);
|
||||
logAction(session.actor, "user.delete", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function setUserRole(req, res, session) {
|
||||
if (!can(session, "TEAM_ASSIGN_ROLE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
const roleId = body.roleId || null;
|
||||
db.prepare(`UPDATE users SET role_id = ? WHERE id = ?`).run(roleId, id);
|
||||
logAction(session.actor, "user.set_role", id, { roleId });
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function setUserPermissions(req, res, session) {
|
||||
if (!can(session, "TEAM_EDIT_PERMISSIONS")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
const overrides = body.overrides && typeof body.overrides === "object" ? body.overrides : {};
|
||||
|
||||
db.prepare(`DELETE FROM user_permission_overrides WHERE user_id = ?`).run(id);
|
||||
for (const [key, allowed] of Object.entries(overrides)) {
|
||||
if (!isValidPermissionKey(key)) continue; // Owner-only-Aktionen können hier nie landen
|
||||
db.prepare(`INSERT INTO user_permission_overrides (user_id, permission, allowed) VALUES (?, ?, ?)`).run(id, key, allowed ? 1 : 0);
|
||||
}
|
||||
logAction(session.actor, "user.set_permissions", id, { overrides });
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/** NUR Owner: alle Sitzungen einer Person sofort beenden ("alle Geräte abmelden"). */
|
||||
export async function endUserSessions(req, res, session) {
|
||||
if (!session?.isOwner) return json(res, { ok: false, error: "Nur der Hauptadministrator darf Sitzungen beenden." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
endAllSessionsForUser(id);
|
||||
logAction(session.actor, "user.end_sessions", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function updateUserMeta(req, res, session) {
|
||||
if (!session?.isOwner) return json(res, { ok: false, error: "Nur der Hauptadministrator darf diese Angaben ändern." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
const user = loadUser(id);
|
||||
if (!user) return json(res, { ok: false, error: "Benutzer nicht gefunden." }, 404);
|
||||
|
||||
const name = body.name !== undefined ? String(body.name).trim().slice(0, 80) || user.name : user.name;
|
||||
const email = body.email !== undefined ? String(body.email).trim() || null : user.email;
|
||||
const note = body.note !== undefined ? String(body.note).slice(0, 500) || null : user.note;
|
||||
let expiresAt = user.expires_at;
|
||||
if (body.expiresAt !== undefined) expiresAt = body.expiresAt ? new Date(body.expiresAt).toISOString() : null;
|
||||
if (body.expiresInDays) expiresAt = new Date(Date.now() + Number(body.expiresInDays) * 86400000).toISOString();
|
||||
|
||||
db.prepare(`UPDATE users SET name = ?, email = ?, note = ?, expires_at = ? WHERE id = ?`).run(name, email, note, expiresAt, id);
|
||||
logAction(session.actor, "user.update_meta", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
Reference in New Issue
Block a user