Alle ausstehenden Aenderungen (Bilder, Texte) fuer den Server-Umzug uebernommen

This commit is contained in:
2026-08-05 18:05:53 +02:00
parent 9af0698056
commit e331965bf1
87 changed files with 8296 additions and 309 deletions
@@ -0,0 +1,228 @@
/* =====================================================================
routes/supporter-admin.js — Administrationsbereich für das Supporter-
Abo (Dogfather_VanVan_Supporter_Abo.odt Abschnitt 27).
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 { 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() {
return json({ ok: false, error: "Keine Berechtigung." }, 403);
}
/** Abonnentenübersicht mit Filtern (Abschnitt 27). */
export async function listSupporters(request, env, session) {
if (!can(session, "SUPPORTER_VIEW")) return forbidden();
const body = await request.json().catch(() => ({}));
const filter = body.filter || "alle";
const rows = await env.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.results || [];
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({ 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(request, env, session) {
if (!can(session, "SUPPORTER_VIEW")) return forbidden();
const premiumsRes = await env.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 paymentsRes = await env.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({ ok: true, premiums: premiumsRes.results || [], payments: paymentsRes.results || [] });
}
/** Details + Prämien eines einzelnen Supporters. */
export async function getSupporterDetail(request, env, session) {
if (!can(session, "SUPPORTER_VIEW")) return forbidden();
const body = await request.json().catch(() => ({}));
const supporter = await env.DB.prepare(`SELECT * FROM supporter_users WHERE id = ?`).bind(body.supporterId).first();
if (!supporter) return json({ ok: false, error: "Nicht gefunden." }, 404);
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 premiums = await env.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`
)
.bind(supporter.id)
.all();
const payments = await env.DB.prepare(
`SELECT * FROM supporter_payments WHERE supporter_id = ? ORDER BY paid_at DESC LIMIT 50`
)
.bind(supporter.id)
.all();
const totalPaidMonths = sub?.total_paid_months || 0;
const progress = computeProgress(totalPaidMonths);
const timeline = timelineForCurrentCycle(totalPaidMonths);
return json({
ok: true, supporter, subscription: sub, premiums: premiums.results || [], payments: payments.results || [],
progress, timeline,
});
}
/** Prämie/Versand manuell bearbeiten (Abschnitt 27 "Prämienverwaltung"). */
export async function updatePremium(request, env, session) {
if (!can(session, "SUPPORTER_MANAGE_PREMIUMS")) return forbidden();
const body = await request.json().catch(() => ({}));
const premium = await env.DB.prepare(`SELECT * FROM supporter_premiums WHERE id = ?`).bind(body.premiumId).first();
if (!premium) return json({ 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;
await env.DB.prepare(
`UPDATE supporter_premiums SET redeem_status = ?, tracking_number = ?, shipped_at = ?, delivered_at = ?, admin_note = ? WHERE id = ?`
)
.bind(nextStatus, trackingNumber || null, shippedAt, deliveredAt, body.adminNote ?? premium.admin_note, premium.id)
.run();
await logAction(env, session.actor, "supporter.premium_updated", premium.id, {
from: premium.redeem_status, to: nextStatus,
});
return json({ ok: true });
}
/** Kollektionen anlegen/bearbeiten (Abschnitt 27 "Kollektionen verwalten"). */
export async function listCollections(request, env, session) {
if (!can(session, "SUPPORTER_VIEW")) return forbidden();
const rows = await env.DB.prepare(`SELECT * FROM supporter_collections ORDER BY collection_number ASC`).all();
return json({ ok: true, collections: rows.results || [] });
}
export async function upsertCollection(request, env, session) {
if (!can(session, "SUPPORTER_MANAGE_COLLECTIONS")) return forbidden();
const body = await request.json().catch(() => ({}));
const num = Number(body.collectionNumber);
if (!Number.isFinite(num) || num < 1) return json({ ok: false, error: "Ungültige Kollektionsnummer." }, 400);
const existing = await env.DB.prepare(`SELECT id FROM supporter_collections WHERE collection_number = ?`).bind(num).first();
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) {
await env.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=?`
)
.bind(
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
)
.run();
} else {
await env.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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
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()
)
.run();
}
await logAction(env, session.actor, "supporter.collection_saved", id, { collectionNumber: num });
return json({ ok: true, id });
}
@@ -0,0 +1,320 @@
/* =====================================================================
routes/supporter-paypal.js — PayPal-Abo erstellen + Webhook-Verarbeitung
(Dogfather_VanVan_Supporter_Abo.odt Abschnitt 6-8).
===================================================================== */
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() {
return json(
{
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(request, env, supporter) {
const body = await request.json().catch(() => ({}));
const origin = body.origin || "https://dogfather-universe.dogfather1608.workers.dev";
try {
const { paypalSubscriptionId, approveUrl } = await createSubscription(env, {
supporterId: supporter.id,
returnUrl: `${origin}/supporter.html?paypal=success`,
cancelUrl: `${origin}/abonnieren.html?paypal=cancelled`,
});
if (!approveUrl) return json({ ok: false, error: "PayPal hat keinen Bestätigungslink geliefert." }, 502);
const subId = generateId();
await env.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, ?, ?)`
)
.bind(subId, supporter.id, paypalSubscriptionId, env.PAYPAL_PLAN_ID, nowIso(), nowIso())
.run();
await logAction(env, "system", "supporter.subscription_started", supporter.id, { paypalSubscriptionId });
return json({ ok: true, approveUrl });
} catch (err) {
if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse();
return json({ 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(request, env, supporter) {
const body = await request.json().catch(() => ({}));
const paypalSubscriptionId = String(body.subscriptionId || "").trim();
if (!paypalSubscriptionId) return json({ 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({ ok: false, error: "Bitte bestätige zuerst die Checkbox zum sofortigen Leistungsbeginn." }, 400);
}
const existing = await env.DB.prepare(`SELECT id FROM supporter_subscriptions WHERE paypal_subscription_id = ?`)
.bind(paypalSubscriptionId)
.first();
if (existing) return json({ ok: true }); // schon verknüpft (z.B. doppelter Klick) — kein Fehler
const subId = generateId();
await env.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, ?, ?)`
)
.bind(subId, supporter.id, paypalSubscriptionId, env.PAYPAL_PLAN_ID, nowIso(), nowIso())
.run();
await logAction(env, "system", "supporter.subscription_confirmed_client_side", supporter.id, {
paypalSubscriptionId,
widerrufConsentAt: consentDatum.toISOString(),
});
return json({ ok: true });
}
export async function cancelPaypalSubscription(request, env, supporter) {
const sub = await env.DB.prepare(
`SELECT * FROM supporter_subscriptions WHERE supporter_id = ? AND status IN ('active','pending','suspended') ORDER BY created_at DESC LIMIT 1`
)
.bind(supporter.id)
.first();
if (!sub) return json({ ok: false, error: "Kein aktives Abonnement gefunden." }, 404);
try {
if (sub.paypal_subscription_id) await cancelSubscription(env, sub.paypal_subscription_id);
} catch (err) {
if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse();
return json({ ok: false, error: "Kündigung bei PayPal fehlgeschlagen." }, 502);
}
await env.DB.prepare(
`UPDATE supporter_subscriptions SET status = 'cancelled', cancelled_at = ?, updated_at = ? WHERE id = ?`
)
.bind(nowIso(), nowIso(), sub.id)
.run();
await logAction(env, "system", "supporter.subscription_cancelled", supporter.id, null);
return json({ ok: true });
}
/* ---------- Webhook (öffentlich erreichbar, aber signaturgeprüft) ---------- */
async function findSubscriptionByPaypalId(env, paypalSubscriptionId) {
if (!paypalSubscriptionId) return null;
return env.DB.prepare(`SELECT * FROM supporter_subscriptions WHERE paypal_subscription_id = ?`)
.bind(paypalSubscriptionId)
.first();
}
async function alreadyProcessed(env, eventId) {
if (!eventId) return false;
const row = await env.DB.prepare(`SELECT id FROM supporter_payments WHERE paypal_event_id = ?`).bind(eventId).first();
return !!row;
}
/** Erfolgreiche Zahlung: Monat anrechnen, ggf. neue Prämien freischalten (Abschnitt 8, 11-13). */
async function handleSuccessfulPayment(env, sub, event, resource) {
const isSandbox = env.PAYPAL_ENV !== "live";
const eventId = event.id;
const paymentId = generateId();
await env.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', ?, ?, ?, ?)`
)
.bind(
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()
)
.run();
if (isSandbox) return; // Testzahlungen zählen NIE als echter Monat (Abschnitt 8/34).
const oldTotal = sub.total_paid_months;
const newTotal = oldTotal + 1;
await env.DB.prepare(`UPDATE supporter_subscriptions SET total_paid_months = ?, updated_at = ? WHERE id = ?`)
.bind(newTotal, nowIso(), sub.id)
.run();
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 =
(await env.DB.prepare(`SELECT id FROM supporter_collections WHERE collection_number = ?`).bind(m.cycleNumber).first()) ||
(await env.DB.prepare(`SELECT id FROM supporter_collections WHERE collection_number = 1`).first());
if (!collection) continue;
await env.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')`
)
.bind(generateId(), sub.supporter_id, collection.id, m.cycleNumber, m.month, m.type, nowIso())
.run();
const supporterRow = await env.DB.prepare(`SELECT display_name, email FROM supporter_users WHERE id = ?`)
.bind(sub.supporter_id)
.first();
if (supporterRow) {
await sendMilestoneUnlocked(env, {
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.
}
await logAction(env, "system", "supporter.milestone_unlocked", sub.supporter_id, { cycle: m.cycleNumber, month: m.month, type: m.type });
}
}
async function handleReversedPayment(env, sub, event, resource) {
const eventId = event.id;
const paymentId = generateId();
await env.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, ?, ?)`
)
.bind(
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",
env.PAYPAL_ENV !== "live" ? 1 : 0, nowIso(), nowIso()
)
.run();
// 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").
await logAction(env, "admin", "supporter.payment_flagged_for_review", sub.supporter_id, {
eventType: event.event_type,
transactionId: resource.id,
});
}
export async function handlePaypalWebhook(request, env) {
const rawBody = await request.text();
let event;
try {
event = JSON.parse(rawBody);
} catch {
return json({ ok: false, error: "Ungültiger Webhook-Body." }, 400);
}
let verified = false;
try {
verified = await verifyWebhookSignature(env, request, rawBody);
} catch (err) {
if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse();
return json({ ok: false, error: "Webhook-Signaturprüfung fehlgeschlagen." }, 400);
}
if (!verified) return json({ ok: false, error: "Ungültige Webhook-Signatur." }, 400);
if (await alreadyProcessed(env, event.id)) return json({ ok: true, duplicate: true });
const resource = event.resource || {};
const type = event.event_type;
try {
if (type === "BILLING.SUBSCRIPTION.ACTIVATED") {
const sub = await findSubscriptionByPaypalId(env, resource.id);
if (sub) {
await env.DB.prepare(
`UPDATE supporter_subscriptions SET status = 'active', started_at = COALESCE(started_at, ?),
next_payment_at = ?, updated_at = ? WHERE id = ?`
)
.bind(nowIso(), resource.billing_info?.next_billing_time || null, nowIso(), sub.id)
.run();
}
} else if (type === "BILLING.SUBSCRIPTION.UPDATED") {
const sub = await findSubscriptionByPaypalId(env, resource.id);
if (sub) {
await env.DB.prepare(`UPDATE supporter_subscriptions SET next_payment_at = ?, updated_at = ? WHERE id = ?`)
.bind(resource.billing_info?.next_billing_time || sub.next_payment_at, nowIso(), sub.id)
.run();
}
} else if (type === "BILLING.SUBSCRIPTION.CANCELLED") {
const sub = await findSubscriptionByPaypalId(env, resource.id);
if (sub) {
await env.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 = ?`
)
.bind(nowIso(), nowIso(), sub.id)
.run();
}
} else if (type === "BILLING.SUBSCRIPTION.SUSPENDED") {
const sub = await findSubscriptionByPaypalId(env, resource.id);
if (sub) await env.DB.prepare(`UPDATE supporter_subscriptions SET status = 'suspended', updated_at = ? WHERE id = ?`).bind(nowIso(), sub.id).run();
} else if (type === "BILLING.SUBSCRIPTION.EXPIRED") {
const sub = await findSubscriptionByPaypalId(env, resource.id);
if (sub) await env.DB.prepare(`UPDATE supporter_subscriptions SET status = 'expired', updated_at = ? WHERE id = ?`).bind(nowIso(), sub.id).run();
} else if (type === "PAYMENT.SALE.COMPLETED") {
const sub = await findSubscriptionByPaypalId(env, resource.billing_agreement_id);
if (sub) await handleSuccessfulPayment(env, sub, event, resource);
} else if (type === "PAYMENT.SALE.DENIED") {
const sub = await findSubscriptionByPaypalId(env, resource.billing_agreement_id);
if (sub) await logAction(env, "system", "supporter.payment_failed", sub.supporter_id, { transactionId: resource.id });
} else if (type === "PAYMENT.SALE.REFUNDED" || type === "PAYMENT.SALE.REVERSED") {
const sub = await findSubscriptionByPaypalId(env, resource.billing_agreement_id);
if (sub) await handleReversedPayment(env, 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({ ok: false, error: "Webhook-Verarbeitung fehlgeschlagen." }, 500);
}
return json({ ok: true });
}
+409
View File
@@ -0,0 +1,409 @@
/* =====================================================================
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 });
}