Nutzer-Wunsch 21.08.2026: "bei der ersten registrierung sollen die eine email bekommen mit einem einmaligen code damit wir auch wissen dass email stimmt und danach wenn sie den code eingegeben haben sollen die erst ihren zugangscode auswählen/eintippen können." Damit wird eine echte Lücke geschlossen: registerSupporter() hat bisher `email_verified = 1` gesetzt, OHNE dass irgendetwas geprüft wurde -- man konnte sich mit einer fremden oder erfundenen Adresse registrieren und kam sofort rein. Neuer Ablauf in drei Schritten: 1. Name/TikTok/E-Mail -> Konto wird als UNBESTÄTIGT angelegt (email_verified = 0, noch kein Zugangscode). Es kommt bewusst KEIN Session-Token zurück -- eingeloggt ist man hier noch nicht. 2. Einmalcode aus der E-Mail eingeben -> verify-email bestätigt und loggt ein. Die Willkommens-Mail wandert hierher, sie ging vorher an eine noch ungeprüfte Adresse. 3. Erst jetzt den eigenen Zugangscode festlegen. Serverseitig abgesichert: setSupporterAccessCode() lehnt ab, solange die E-Mail nicht bestätigt ist -- der Schritt ist damit nicht nur im Formular versteckt, sondern auch per Direktaufruf nicht überspringbar. Wiederverwendet wird die bereits vorhandene Mechanik (issueAuthCode/ verifyAuthCode mit Zweck "verify_email", sendVerifyEmailCode, die Panels #panel-code und #panel-neuer-code) -- der "Code vergessen?"-Weg nutzt dieselbe Code-Eingabe und bleibt unverändert; eine neue Variable codeZweck unterscheidet, welcher Endpunkt aufgerufen wird. Mit Playwright end-to-end geprüft (14 Tests): Reihenfolge der Aufrufe, kein Token vor der Bestätigung, falscher Code kommt nicht weiter, Zugangscode-Feld erst im letzten Schritt, "Code vergessen?" unberührt. Cache-Busting-Version auf 20260821o erhöht. Co-Authored-By: Claude Opus 5 <[email protected]>
531 lines
24 KiB
JavaScript
531 lines
24 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. */
|
|
/* Schritt 1 von 3 der Registrierung (Nutzer-Wunsch 21.08.2026: "bei der
|
|
ersten registrierung sollen die eine email bekommen mit einem einmaligen
|
|
code damit wir auch wissen dass email stimmt und danach wenn sie den code
|
|
eingegeben haben sollen die erst ihren zugangscode auswählen/eintippen
|
|
können").
|
|
|
|
Vorher wurde hier direkt `email_verified = 1` gesetzt UND der Zugangscode
|
|
im selben Schritt entgegengenommen -- die E-Mail-Adresse wurde also nie
|
|
geprüft. Man konnte sich mit einer fremden oder erfundenen Adresse
|
|
registrieren und kam sofort rein.
|
|
|
|
Jetzt legt dieser Schritt nur das UNBESTÄTIGTE Konto an (email_verified = 0,
|
|
noch KEIN Zugangscode) und verschickt einen Einmalcode. Weiter geht es über
|
|
verifySupporterEmail() (Schritt 2) und erst danach setSupporterAccessCode()
|
|
(Schritt 3). Es wird hier bewusst KEIN Session-Token zurückgegeben -- ohne
|
|
bestätigte E-Mail ist man nicht eingeloggt. */
|
|
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, 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);
|
|
}
|
|
|
|
let supporterId;
|
|
if (existing) {
|
|
// Konto existiert, ist aber noch unfertig (abgebrochener Versuch, oder
|
|
// Altkonto ohne eigenen Code) -> Angaben auffrischen, Bestätigung neu
|
|
// anstoßen. Kein Fehler nach außen: ein zweiter Anlauf soll einfach
|
|
// funktionieren, statt in einer Sackgasse zu enden.
|
|
supporterId = existing.id;
|
|
db.prepare(
|
|
`UPDATE supporter_users
|
|
SET display_name = ?, tiktok_username = ?, email_verified = 0
|
|
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 });
|
|
}
|
|
|
|
// Anders als die Willkommens-E-Mail ist DIESE Mail ein echter Blocker:
|
|
// ohne den Code kommt niemand weiter. Schlägt der Versand fehl, muss das
|
|
// sichtbar scheitern statt so zu tun, als sei alles in Ordnung.
|
|
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 });
|
|
}
|
|
|
|
/* 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);
|
|
|
|
// Zusätzliche Absicherung (21.08.2026): Ein Zugangscode darf NUR gesetzt
|
|
// werden, wenn die E-Mail-Adresse bestätigt ist. Aktuell kann ohne
|
|
// Bestätigung ohnehin keine Session entstehen (registerSupporter gibt kein
|
|
// Token mehr zurück) -- diese Prüfung hängt aber nicht davon ab, dass das
|
|
// so bleibt. Ohne sie wäre der Bestätigungsschritt nur im Formular
|
|
// versteckt und ließe sich mit einem direkten Aufruf überspringen.
|
|
const stand = db.prepare(`SELECT email_verified FROM supporter_users WHERE id = ?`).get(supporter.id);
|
|
if (!stand || !stand.email_verified) {
|
|
return bad(res, "Bitte bestätige zuerst deine E-Mail-Adresse.", 403);
|
|
}
|
|
|
|
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);
|
|
|
|
// Willkommens-E-Mail erst hier (21.08.2026) -- vorher ging sie schon beim
|
|
// Registrieren raus, also an eine noch ungeprüfte Adresse. Jetzt steht
|
|
// fest, dass die Adresse wirklich der Person gehört. Weiterhin
|
|
// nice-to-have: schlägt sie fehl, blockiert das die Anmeldung nicht.
|
|
try {
|
|
await sendWelcomeMail({ to: email, name: supporter.display_name });
|
|
} catch (err) {
|
|
logAction("system", "supporter.welcome_mail_failed", supporter.id, { fehler: String(err?.message || err).slice(0, 200) });
|
|
}
|
|
|
|
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 });
|
|
}
|