Diese Änderungen liefen bereits live auf dem Server, lagen dort aber ausschließlich als nicht eingecheckte Arbeitskopie — bei jedem Deploy (stash/pull/pop) und bei jedem Serverproblem wären sie verloren gewesen. Deshalb hier unverändert in git übernommen, bevor darauf aufgebaut wird. Enthalten (nicht von mir gebaut, nur gesichert): - Supporter: eigener fester Zugangscode statt Einmalcode-Login (Migration 0009, lib/crypto.js scrypt-Hash, routes/supporter.js, abonnieren.html, supporter.html, i18n-abonnieren/-supporter) - Stimmen: Profilbilder (Migration 0010, routes/testimonials.js) - Event-Bild-Upload: voller Pfad statt relativem (routes/events.js) - Neue/überarbeitete Hintergrundbilder für viele Seiten - Teilen-Funktion (streamplan.js, i18n-index/-streamplan, main.css) Co-Authored-By: Claude Opus 5 <[email protected]>
495 lines
22 KiB
JavaScript
495 lines
22 KiB
JavaScript
/* =====================================================================
|
|
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, hashAccessCode, verifyAccessCode } from "../lib/crypto.js";
|
|
import { json } from "../lib/http.js";
|
|
import { logAction } from "../lib/audit.js";
|
|
import { isLockedOut, recordFailedAttempt, clearAttempts } from "../lib/auth.js";
|
|
import {
|
|
normalizeEmail, normalizeTikTokUsername, issueAuthCode, verifyAuthCode,
|
|
createSupporterSession, endSupporterSession,
|
|
} from "../lib/supporter-auth.js";
|
|
import { EmailNotConfiguredError, sendVerifyEmailCode, sendLoginCode, sendWelcomeMail } 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) ---------- */
|
|
|
|
/* Mindestlänge für den selbst gewählten Zugangscode. Bewusst nur 6 Zeichen
|
|
(statt der sonst üblichen 8+): der Bereich enthält keine Zahlungsdaten,
|
|
und ein zu strenger Zwang würde hier mehr Leute abschrecken als schützen.
|
|
Gegen Durchprobieren schützt die Sperre nach mehreren Fehlversuchen
|
|
(siehe loginSupporterMitCode) plus das langsame scrypt-Verfahren. */
|
|
const MIN_CODE_LAENGE = 6;
|
|
|
|
function pruefeCode(code) {
|
|
const sauber = String(code || "");
|
|
if (sauber.length < MIN_CODE_LAENGE) {
|
|
return `Dein Zugangscode muss mindestens ${MIN_CODE_LAENGE} Zeichen lang sein.`;
|
|
}
|
|
if (sauber.length > 200) return "Dein Zugangscode ist zu lang.";
|
|
return null;
|
|
}
|
|
|
|
/* Registrierung — seit 21.08.2026 (Nutzer-Wunsch: "die leute sollen nach der
|
|
registrierung einen festen code erstellen können für sich selbst und
|
|
fertig, mit dem sie rein kommen"): man legt hier direkt seinen eigenen,
|
|
dauerhaften Zugangscode fest und ist danach SOFORT eingeloggt. Vorher lief
|
|
auch die Registrierung über einen Einmalcode per E-Mail — ein Extraschritt,
|
|
der nichts brachte, weil man denselben Code danach nie wieder brauchte.
|
|
|
|
Die E-Mail-Adresse gilt dabei als bestätigt (email_verified = 1). Ohne das
|
|
käme man später nicht mehr an den "Code vergessen?"-Weg heran, der ja
|
|
genau über diese Adresse läuft — und ein zusätzlicher Bestätigungsschritt
|
|
wäre wieder genau der Umweg, der hier abgeschafft werden sollte. Zur
|
|
Kontrolle geht eine Willkommens-E-Mail raus; schlägt deren Versand fehl,
|
|
ist das KEIN Grund die Registrierung scheitern zu lassen (das Konto ist
|
|
ja schon nutzbar) — sie wird nur im Protokoll vermerkt. */
|
|
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);
|
|
const accessCode = String(body.accessCode || "");
|
|
|
|
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 codeFehler = pruefeCode(accessCode);
|
|
if (codeFehler) return bad(res, codeFehler);
|
|
|
|
const existing = db.prepare(`SELECT id, access_code_hash FROM supporter_users WHERE email = ?`).get(email);
|
|
if (existing && existing.access_code_hash) {
|
|
return bad(res, "Für diese E-Mail-Adresse gibt es schon ein Konto. Bitte logg dich einfach ein.", 409);
|
|
}
|
|
|
|
const codeHash = await hashAccessCode(accessCode);
|
|
let supporterId;
|
|
if (existing) {
|
|
// Konto war angelegt, hatte aber noch keinen eigenen Code (z.B. ein
|
|
// abgebrochener Versuch aus der Zeit davor) -> jetzt vervollständigen.
|
|
supporterId = existing.id;
|
|
db.prepare(
|
|
`UPDATE supporter_users
|
|
SET display_name = ?, tiktok_username = ?, email_verified = 1,
|
|
access_code_hash = ?, access_code_set_at = ?
|
|
WHERE id = ?`
|
|
).run(displayName, tiktokUsername, codeHash, nowIso(), supporterId);
|
|
} else {
|
|
supporterId = generateId();
|
|
db.prepare(
|
|
`INSERT INTO supporter_users
|
|
(id, display_name, tiktok_username, email, email_verified, created_at, access_code_hash, access_code_set_at, last_login_at)
|
|
VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?)`
|
|
).run(supporterId, displayName, tiktokUsername, email, nowIso(), codeHash, nowIso(), nowIso());
|
|
logAction("system", "supporter.registered", supporterId, { email });
|
|
}
|
|
|
|
// Willkommens-E-Mail: nice-to-have, kein Blocker (siehe Kommentar oben).
|
|
try {
|
|
await sendWelcomeMail({ to: email, name: displayName });
|
|
} catch (err) {
|
|
logAction("system", "supporter.welcome_mail_failed", supporterId, { fehler: String(err?.message || err).slice(0, 200) });
|
|
}
|
|
|
|
const token = await createSupporterSession(supporterId);
|
|
return json(res, { ok: true, token });
|
|
}
|
|
|
|
/* Einloggen mit E-Mail + selbst gewähltem Code — der neue Normalweg.
|
|
Schutz gegen Durchprobieren: nach mehreren Fehlversuchen ist die
|
|
Kombination für eine Weile gesperrt (dieselbe Mechanik wie beim
|
|
Team-Login, siehe lib/auth.js). Der Schlüssel enthält bewusst die
|
|
E-Mail-Adresse, damit ein Angreifer nicht durch Sperren FREMDER Konten
|
|
Schaden anrichten kann, ohne deren Adresse zu kennen. */
|
|
export async function loginSupporterMitCode(req, res) {
|
|
const body = req.body || {};
|
|
const email = normalizeEmail(body.email);
|
|
const accessCode = String(body.accessCode || "");
|
|
if (!email || !accessCode) return bad(res, "Bitte E-Mail-Adresse und Zugangscode eingeben.");
|
|
|
|
const sperrSchluessel = `supporter:${email}`;
|
|
if (isLockedOut(sperrSchluessel)) {
|
|
return bad(res, "Zu viele Fehlversuche. Bitte versuch es in ein paar Minuten noch einmal.", 429);
|
|
}
|
|
|
|
const supporter = db
|
|
.prepare(`SELECT id, display_name, access_code_hash FROM supporter_users WHERE email = ? AND status = 'active'`)
|
|
.get(email);
|
|
|
|
// Konto existiert, wurde aber vor der Umstellung angelegt und hat noch
|
|
// keinen eigenen Code -> gezielter Hinweis statt "falsche Daten".
|
|
if (supporter && !supporter.access_code_hash) {
|
|
return json(
|
|
res,
|
|
{ ok: false, code: "KEIN_CODE_GESETZT", error: "Für dieses Konto ist noch kein Zugangscode festgelegt. Nutze bitte „Code vergessen?“, um dir einen zu setzen." },
|
|
409
|
|
);
|
|
}
|
|
|
|
const passt = supporter ? await verifyAccessCode(accessCode, supporter.access_code_hash) : false;
|
|
if (!passt) {
|
|
recordFailedAttempt(sperrSchluessel);
|
|
// Bewusst dieselbe Meldung, egal ob die E-Mail unbekannt ist oder nur
|
|
// der Code nicht stimmt — sonst könnte man von außen durchprobieren,
|
|
// welche Adressen überhaupt registriert sind.
|
|
return bad(res, "E-Mail-Adresse oder Zugangscode stimmt nicht.", 401);
|
|
}
|
|
|
|
clearAttempts(sperrSchluessel);
|
|
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 });
|
|
}
|
|
|
|
/* Neuen Zugangscode setzen — für eingeloggte Leute (freiwillig ändern) UND
|
|
als letzter Schritt des "Code vergessen?"-Weges (dort ist man durch den
|
|
E-Mail-Einmalcode bereits eingeloggt). */
|
|
export async function setSupporterAccessCode(req, res, supporter) {
|
|
const body = req.body || {};
|
|
const neuerCode = String(body.accessCode || "");
|
|
const fehler = pruefeCode(neuerCode);
|
|
if (fehler) return bad(res, fehler);
|
|
|
|
const codeHash = await hashAccessCode(neuerCode);
|
|
db.prepare(`UPDATE supporter_users SET access_code_hash = ?, access_code_set_at = ? WHERE id = ?`).run(codeHash, nowIso(), supporter.id);
|
|
clearAttempts(`supporter:${supporter.email}`);
|
|
logAction("system", "supporter.access_code_set", supporter.id, null);
|
|
return json(res, { ok: 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 });
|
|
}
|
|
|
|
/* Seit 21.08.2026 ist das NICHT mehr der normale Login-Weg, sondern der
|
|
"Code vergessen?"-Weg: hierüber bekommt man einen Einmalcode per E-Mail,
|
|
ist danach eingeloggt und kann sich einen neuen dauerhaften Zugangscode
|
|
setzen (siehe setSupporterAccessCode). */
|
|
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 FROM supporter_users WHERE email = ? AND status = 'active'`)
|
|
.get(email);
|
|
// Bewusst IMMER ok:true nach außen (keine Auskunft, ob ein Konto existiert).
|
|
// Die frühere zusätzliche Bedingung "nur wenn email_verified" ist hier
|
|
// entfallen: genau die Konten aus der Zeit vor der Umstellung (die nie
|
|
// bestätigt wurden und noch keinen eigenen Code haben) brauchen diesen Weg
|
|
// am dringendsten — sonst kämen sie gar nicht mehr rein.
|
|
if (!supporter) 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 });
|
|
}
|