/* ===================================================================== routes/supporter.js — Öffentlicher Supporter-Bereich: Registrierung, E-Mail-Bestätigung, Magic-Link-Login, persönliches Dashboard, Prämien- Einlösung, Aboverwaltung. Setzt Dogfather_VanVan_Supporter_Abo.odt Abschnitt 5, 6, 9-18, 21-25 um. ===================================================================== */ 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, MILESTONES } from "../lib/supporter-cycle.js"; import { verifyGoogleIdToken, GoogleAuthNotConfiguredError } from "../lib/google-auth.js"; function bad(msg, status = 400) { return json({ ok: false, error: msg }, status); } function emailErrorResponse(err) { if (err instanceof EmailNotConfiguredError || err.code === "EMAIL_NOT_CONFIGURED") { return json( { ok: false, error: "Der E-Mail-Versand ist auf dieser Website noch nicht eingerichtet (RESEND_API_KEY fehlt). " + "Bitte zuerst im Worker-Setup konfigurieren, siehe cloudflare-worker/README.md.", code: "EMAIL_NOT_CONFIGURED", }, 503 ); } return json({ ok: false, error: "E-Mail konnte nicht verschickt werden." }, 502); } /* ---------- Registrierung & Login (öffentlich, keine Session) ---------- */ export async function registerSupporter(request, env) { const body = await request.json().catch(() => ({})); const displayName = String(body.displayName || "").trim().slice(0, 60); const tiktokUsername = normalizeTikTokUsername(body.tiktokUsername); const email = normalizeEmail(body.email); if (!displayName) return bad("Bitte gib einen Namen oder Anzeigenamen an."); if (!email || !email.includes("@")) return bad("Bitte gib eine gültige E-Mail-Adresse an."); const existing = await env.DB.prepare(`SELECT id, email_verified FROM supporter_users WHERE email = ?`) .bind(email) .first(); let supporterId; if (existing) { if (existing.email_verified) { return bad("Für diese E-Mail-Adresse existiert bereits ein Supporter-Konto. Bitte einloggen.", 409); } supporterId = existing.id; await env.DB.prepare(`UPDATE supporter_users SET display_name = ?, tiktok_username = ? WHERE id = ?`) .bind(displayName, tiktokUsername, supporterId) .run(); } else { supporterId = generateId(); await env.DB.prepare( `INSERT INTO supporter_users (id, display_name, tiktok_username, email, email_verified, created_at) VALUES (?, ?, ?, ?, 0, ?)` ) .bind(supporterId, displayName, tiktokUsername, email, nowIso()) .run(); await logAction(env, "system", "supporter.registered", supporterId, { email }); } const code = await issueAuthCode(env, supporterId, "verify_email"); try { await sendVerifyEmailCode(env, { to: email, name: displayName, code }); } catch (err) { return emailErrorResponse(err); } return json({ ok: true, needsVerification: true }); } export async function verifySupporterEmail(request, env) { const body = await request.json().catch(() => ({})); const email = normalizeEmail(body.email); const code = String(body.code || "").trim(); if (!email || !code) return bad("E-Mail und Code werden benötigt."); const supporter = await env.DB.prepare(`SELECT id, display_name FROM supporter_users WHERE email = ?`).bind(email).first(); if (!supporter) return bad("Kein Supporter-Konto mit dieser E-Mail-Adresse gefunden.", 404); const valid = await verifyAuthCode(env, supporter.id, "verify_email", code); if (!valid) return bad("Der Code ist ungültig oder abgelaufen.", 401); await env.DB.prepare(`UPDATE supporter_users SET email_verified = 1, last_login_at = ? WHERE id = ?`) .bind(nowIso(), supporter.id) .run(); await logAction(env, "system", "supporter.email_verified", supporter.id, null); const token = await createSupporterSession(env, supporter.id); return json({ ok: true, token }); } export async function requestSupporterLogin(request, env) { const body = await request.json().catch(() => ({})); const email = normalizeEmail(body.email); if (!email) return bad("Bitte gib deine E-Mail-Adresse an."); const supporter = await env.DB.prepare( `SELECT id, display_name, email_verified FROM supporter_users WHERE email = ? AND status = 'active'` ) .bind(email) .first(); // Bewusst IMMER ok:true nach außen (keine Auskunft, ob ein Konto existiert). if (!supporter || !supporter.email_verified) return json({ ok: true }); const code = await issueAuthCode(env, supporter.id, "login"); try { await sendLoginCode(env, { to: email, name: supporter.display_name, code }); } catch (err) { return emailErrorResponse(err); } return json({ ok: true }); } export async function verifySupporterLogin(request, env) { const body = await request.json().catch(() => ({})); const email = normalizeEmail(body.email); const code = String(body.code || "").trim(); if (!email || !code) return bad("E-Mail und Code werden benötigt."); const supporter = await env.DB.prepare(`SELECT id FROM supporter_users WHERE email = ? AND status = 'active'`).bind(email).first(); if (!supporter) return bad("Kein Supporter-Konto gefunden.", 404); const valid = await verifyAuthCode(env, supporter.id, "login", code); if (!valid) return bad("Der Code ist ungültig oder abgelaufen.", 401); await env.DB.prepare(`UPDATE supporter_users SET last_login_at = ? WHERE id = ?`).bind(nowIso(), supporter.id).run(); const token = await createSupporterSession(env, supporter.id); return json({ 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(request, env) { const body = await request.json().catch(() => ({})); let result; try { result = await verifyGoogleIdToken(env, body.credential); } catch (err) { if (err instanceof GoogleAuthNotConfiguredError) { return json( { ok: false, code: "GOOGLE_AUTH_NOT_CONFIGURED", error: "Google-Login ist auf dieser Website noch nicht eingerichtet.", }, 503 ); } return json({ ok: false, error: "Google-Anmeldung fehlgeschlagen." }, 502); } if (!result.ok) return bad(result.reason || "Google-Anmeldung fehlgeschlagen.", 401); let supporter = await env.DB.prepare(`SELECT id FROM supporter_users WHERE email = ? AND status = 'active'`) .bind(result.email) .first(); if (!supporter) { const id = generateId(); await env.DB.prepare( `INSERT INTO supporter_users (id, display_name, email, email_verified, created_at, last_login_at) VALUES (?, ?, ?, 1, ?, ?)` ) .bind(id, result.name, result.email, nowIso(), nowIso()) .run(); supporter = { id }; await logAction(env, "system", "supporter.registered_via_google", supporter.id, { email: result.email }); } else { await env.DB.prepare(`UPDATE supporter_users SET last_login_at = ? WHERE id = ?`).bind(nowIso(), supporter.id).run(); } const token = await createSupporterSession(env, supporter.id); return json({ 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(request, env) { return json({ ok: true, googleClientId: env.GOOGLE_CLIENT_ID || null, paypalClientId: env.PAYPAL_CLIENT_ID || null, paypalPlanId: env.PAYPAL_PLAN_ID || null, }); } export async function logoutSupporter(request, env) { const auth = request.headers.get("Authorization") || ""; const token = auth.startsWith("Bearer ") ? auth.slice(7) : null; await endSupporterSession(env, token); return json({ 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). */ async function buildDashboard(env, supporter) { const sub = await env.DB.prepare( `SELECT * FROM supporter_subscriptions WHERE supporter_id = ? ORDER BY created_at DESC LIMIT 1` ) .bind(supporter.id) .first(); const totalPaidMonths = sub?.total_paid_months || 0; const progress = computeProgress(totalPaidMonths); const timeline = timelineForCurrentCycle(totalPaidMonths); const premiumsRes = await env.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` ) .bind(supporter.id) .all(); const premiums = (premiumsRes.results || []).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) { const paymentsRes = await env.DB.prepare( `SELECT amount, currency, payment_status, paid_at, is_sandbox FROM supporter_payments WHERE supporter_id = ? ORDER BY paid_at DESC LIMIT 36` ) .bind(supporter.id) .all(); payments = paymentsRes.results || []; } 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(request, env, supporter) { return json({ ok: true, ...(await buildDashboard(env, supporter)) }); } export async function updateSupporterProfile(request, env, supporter) { const body = await request.json().catch(() => ({})); 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("Der Name darf nicht leer sein."); await env.DB.prepare(`UPDATE supporter_users SET display_name = ?, tiktok_username = ? WHERE id = ?`) .bind(displayName, tiktokUsername, supporter.id) .run(); await logAction(env, "system", "supporter.profile_updated", supporter.id, null); return json({ ok: true }); } export async function requestSupporterEmailChange(request, env, supporter) { const body = await request.json().catch(() => ({})); const newEmail = normalizeEmail(body.newEmail); if (!newEmail || !newEmail.includes("@")) return bad("Bitte gib eine gültige neue E-Mail-Adresse an."); const clash = await env.DB.prepare(`SELECT id FROM supporter_users WHERE email = ? AND id != ?`) .bind(newEmail, supporter.id) .first(); if (clash) return bad("Diese E-Mail-Adresse wird bereits verwendet.", 409); await env.DB.prepare(`UPDATE supporter_users SET pending_email = ? WHERE id = ?`).bind(newEmail, supporter.id).run(); const code = await issueAuthCode(env, supporter.id, "change_email"); try { await sendVerifyEmailCode(env, { to: newEmail, name: supporter.display_name, code }); } catch (err) { return emailErrorResponse(err); } return json({ ok: true }); } export async function confirmSupporterEmailChange(request, env, supporter) { const body = await request.json().catch(() => ({})); const code = String(body.code || "").trim(); if (!supporter.pending_email) return bad("Es liegt keine E-Mail-Änderung vor."); const valid = await verifyAuthCode(env, supporter.id, "change_email", code); if (!valid) return bad("Der Code ist ungültig oder abgelaufen.", 401); await env.DB.prepare(`UPDATE supporter_users SET email = pending_email, pending_email = NULL WHERE id = ?`) .bind(supporter.id) .run(); await logAction(env, "system", "supporter.email_changed", supporter.id, null); return json({ ok: true }); } /** Prämie einlösen (Abschnitt 17) — Versandadresse erst hier, nicht bei der Registrierung. */ export async function redeemPremium(request, env, supporter) { const body = await request.json().catch(() => ({})); const premium = await env.DB.prepare(`SELECT * FROM supporter_premiums WHERE id = ? AND supporter_id = ?`) .bind(body.premiumId, supporter.id) .first(); if (!premium) return bad("Prämie nicht gefunden.", 404); if (premium.redeem_status !== "verfuegbar") return bad("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("Bitte fülle alle Adressfelder aus."); } if (premium.premium_type === "hoodie" && !body.hoodieSize) { return bad("Bitte wähle eine Hoodie-Größe."); } await env.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 = ?` ) .bind( nowIso(), addr.name, addr.street, addr.zip, addr.city, addr.country, body.hoodieSize || null, body.hoodieVariant || null, premium.id ) .run(); if (body.saveAddress) { await env.DB.prepare( `UPDATE supporter_users SET ship_name=?, ship_street=?, ship_zip=?, ship_city=?, ship_country=?, ship_save_address=1 WHERE id=?` ) .bind(addr.name, addr.street, addr.zip, addr.city, addr.country, supporter.id) .run(); } await logAction(env, "system", "supporter.premium_redeemed", supporter.id, { premiumId: premium.id, type: premium.premium_type }); return json({ ok: true }); } export async function deleteSupporterAccount(request, env, 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. await env.DB.prepare(`UPDATE supporter_users SET status = 'deleted', email = email || '.geloescht.' || id WHERE id = ?`) .bind(supporter.id) .run(); await logAction(env, "system", "supporter.account_deleted", supporter.id, null); return json({ ok: true }); }