Alle ausstehenden Aenderungen (Bilder, Texte) fuer den Server-Umzug uebernommen
This commit is contained in:
@@ -217,6 +217,171 @@ Auf `zugaenge.html` (verlinkt von `postfach.html`, wenn du als Owner eingeloggt
|
||||
- CORS ist aktuell offen (`*`) — sobald die finale Domain feststeht, in `src/lib/http.js` →
|
||||
`corsHeaders()` auf die echte Domain einschränken.
|
||||
|
||||
## Supporter-Abo (Dogfather) — Setup
|
||||
|
||||
Setzt `Dogfather_VanVan_Supporter_Abo.odt` um: eigener, öffentlicher
|
||||
Supporter-Bereich mit PayPal-Abo (4,99 €/Monat), 30-Monats-Prämienzyklus
|
||||
(Hasen-Teddys → Tasse & Autogrammkarte → Hoodie) und wechselnden
|
||||
Kollektionen. **Wichtig: Das Abo selbst gehört nur Dogfather** — die
|
||||
Teddy-Kollektion/Sammlerstücke entstehen in Kooperation mit VanVan, aber
|
||||
das Abonnement ist NICHT gemeinsames Branding (klargestellt 03.08.2026).
|
||||
Code ist **vollständig fertig implementiert**, kann aber
|
||||
erst wirklich Geld verarbeiten bzw. E-Mails verschicken, sobald folgende
|
||||
zwei externe Dienste eingerichtet sind — beides Dinge, die **nur der
|
||||
Website-Betreiber selbst** einrichten kann (eigene Identität/Bankkonto):
|
||||
|
||||
```
|
||||
src/lib/supporter-cycle.js Reine Rechenlogik: 30-Monats-Zyklus/Meilensteine
|
||||
src/lib/supporter-auth.js Registrierung/Login (E-Mail-Code, eigene Sessions)
|
||||
src/lib/supporter-mail.js Transaktions-E-Mails (Resend-Adapter)
|
||||
src/lib/paypal.js PayPal Subscriptions API + Webhook-Signaturprüfung
|
||||
src/routes/supporter.js Öffentlicher Supporter-Bereich (Profil/Prämien/Archiv)
|
||||
src/routes/supporter-paypal.js Abo starten/kündigen + Webhook-Verarbeitung
|
||||
src/routes/supporter-admin.js Interner Administrationsbereich (Abonnentenübersicht etc.)
|
||||
migrations/0003_supporter_abo.sql D1-Schema (komplett getrennt vom Team-Zugangssystem)
|
||||
```
|
||||
|
||||
### 1) D1-Migration ausführen (einmalig)
|
||||
|
||||
```bash
|
||||
npx wrangler d1 execute dogfather-universe-db --remote --file=migrations/0003_supporter_abo.sql
|
||||
```
|
||||
|
||||
### 2) PayPal-Business-Konto + Abo-Plan einrichten
|
||||
|
||||
1. PayPal-Business-Konto anlegen (falls noch nicht vorhanden): https://www.paypal.com/business
|
||||
2. Developer-App anlegen: https://developer.paypal.com/dashboard/applications → **Create App**
|
||||
(zuerst im **Sandbox**-Modus testen, siehe unten).
|
||||
3. Ein Produkt anlegen (Katalog-Produkt, Typ "Service") mit dem Namen
|
||||
**„Dogfather Supporter-Abo"**.
|
||||
4. Einen Abo-Plan zu diesem Produkt anlegen: 4,99 € / Monat, EUR, monatliche
|
||||
automatische Verlängerung, **keine** Testphase, **keine** Einrichtungsgebühr.
|
||||
5. Einen Webhook anlegen, Ziel-URL:
|
||||
`https://dogfather-universe-postfach.dogfather1608.workers.dev/supporter/paypal-webhook`
|
||||
Mindestens folgende Ereignisse abonnieren: `BILLING.SUBSCRIPTION.ACTIVATED`,
|
||||
`BILLING.SUBSCRIPTION.UPDATED`, `BILLING.SUBSCRIPTION.CANCELLED`,
|
||||
`BILLING.SUBSCRIPTION.SUSPENDED`, `BILLING.SUBSCRIPTION.EXPIRED`,
|
||||
`PAYMENT.SALE.COMPLETED`, `PAYMENT.SALE.DENIED`, `PAYMENT.SALE.REFUNDED`,
|
||||
`PAYMENT.SALE.REVERSED`.
|
||||
|
||||
Aus diesen Schritten bekommst du 4 Werte, die als Secrets gesetzt werden
|
||||
(NIE im Code, NIE im Repo):
|
||||
|
||||
```bash
|
||||
npx wrangler secret put PAYPAL_CLIENT_ID
|
||||
npx wrangler secret put PAYPAL_CLIENT_SECRET
|
||||
npx wrangler secret put PAYPAL_PLAN_ID
|
||||
npx wrangler secret put PAYPAL_WEBHOOK_ID
|
||||
```
|
||||
|
||||
Und in `wrangler.toml` unter `[vars]` (unkritisch, kein Geheimnis):
|
||||
|
||||
```toml
|
||||
PAYPAL_ENV = "sandbox" # später auf "live" umstellen, siehe Abschnitt 7 des Auftrags
|
||||
```
|
||||
|
||||
**Erst vollständig in der Sandbox testen** (eigenes Sandbox-Business- und
|
||||
Sandbox-Privatkonto unter https://developer.paypal.com/dashboard/accounts),
|
||||
danach `PAYPAL_ENV` auf `"live"` umstellen und Schritte 2-5 mit den echten
|
||||
Live-Zugangsdaten wiederholen. Solange die Secrets fehlen, antworten alle
|
||||
PayPal-Endpunkte kontrolliert mit `PAYPAL_NOT_CONFIGURED` statt abzustürzen.
|
||||
|
||||
**Zahlarten-Abdeckung (03.08.2026 ergänzt):** Das Frontend nutzt jetzt die
|
||||
offiziellen PayPal Smart Payment Buttons (`abonnieren.html`) statt einer
|
||||
reinen Weiterleitung — dadurch zeigt PayPal automatisch ALLE für die
|
||||
jeweilige Käuferin/den jeweiligen Käufer verfügbaren, in Deutschland
|
||||
gängigen Zahlarten an (PayPal-Guthaben/-Bankkonto, Kreditkarte auch als
|
||||
Gast ohne eigenes PayPal-Konto). Das ist bewusst PayPal selbst überlassen
|
||||
(kein eigenes Karten-/SEPA-/Klarna-Backend nötig) — das Geld landet dabei
|
||||
immer direkt auf **deinem eigenen** PayPal-Business-Konto (dieselben
|
||||
`PAYPAL_CLIENT_ID`/`PAYPAL_PLAN_ID` wie oben). Es ist keine zusätzliche
|
||||
Einstellung nötig — welche Zahlarten genau angezeigt werden, entscheidet
|
||||
PayPal automatisch je nach Land/Konto deiner Supporter.
|
||||
|
||||
### 3) "Mit Google anmelden" einrichten (optional, aber empfohlen)
|
||||
|
||||
Zusätzlich zum E-Mail-Code-Login gibt es jetzt einen "Mit Google anmelden"-
|
||||
Button (professionellerer, schnellerer Login — Google hat die E-Mail-Adresse
|
||||
bereits selbst bestätigt, kein Extra-Code nötig). Komplett optional: ohne
|
||||
`GOOGLE_CLIENT_ID` bleibt der Button einfach ausgeblendet, der normale
|
||||
E-Mail-Code-Login funktioniert unverändert.
|
||||
|
||||
1. https://console.cloud.google.com/apis/credentials öffnen (kostenloses
|
||||
Google-Konto reicht, keine Kreditkarte nötig).
|
||||
2. Falls noch nicht vorhanden: **OAuth-Zustimmungsbildschirm** einmalig
|
||||
einrichten (App-Name z.B. "Dogfather Supporter-Abo", externe Nutzer).
|
||||
3. **Anmeldedaten erstellen → OAuth-Client-ID → Webanwendung.**
|
||||
4. Unter **Autorisierte JavaScript-Quellen** deine echte Domain eintragen
|
||||
(sobald vorhanden) sowie zum Testen
|
||||
`https://dogfather-universe.dogfather1608.workers.dev`.
|
||||
5. Die erzeugte Client-ID (endet auf `.apps.googleusercontent.com`) als
|
||||
Secret setzen:
|
||||
|
||||
```bash
|
||||
npx wrangler secret put GOOGLE_CLIENT_ID
|
||||
```
|
||||
|
||||
Kein Client-Secret nötig — die Anmeldung läuft komplett über Googles
|
||||
"Sign In With Google"-Button im Browser, der Worker prüft das dabei
|
||||
ausgestellte Token nur gegen Googles öffentlichen `tokeninfo`-Endpunkt.
|
||||
|
||||
### 4) E-Mail-Versand einrichten (Resend, kostenloser Tier reicht)
|
||||
|
||||
Ohne E-Mail-Versand können sich Supporter nicht registrieren/einloggen
|
||||
(E-Mail-Bestätigung/Login-Code). Benötigt außerdem eine **eigene, per
|
||||
SPF/DKIM verifizierte Domain** (auf `*.workers.dev` allein funktioniert
|
||||
zuverlässiger E-Mail-Versand nicht).
|
||||
|
||||
1. Account auf https://resend.com anlegen (kostenlos bis 3.000 E-Mails/Monat).
|
||||
2. Eigene Domain hinzufügen und die angezeigten DNS-Einträge setzen.
|
||||
3. API-Key erstellen.
|
||||
|
||||
```bash
|
||||
npx wrangler secret put RESEND_API_KEY
|
||||
```
|
||||
|
||||
Und in `wrangler.toml` unter `[vars]`:
|
||||
|
||||
```toml
|
||||
RESEND_FROM = "Dogfather Team <[email protected]>"
|
||||
```
|
||||
|
||||
### 5) Deployen
|
||||
|
||||
```bash
|
||||
npx wrangler deploy
|
||||
```
|
||||
|
||||
### 6) Berechtigung für den Administrationsbereich vergeben
|
||||
|
||||
Auf `zugaenge.html` einer Rolle (oder dir selbst als Owner, der hat ohnehin
|
||||
alles) die neuen Rechte **„Supporter-Abonnenten ansehen"**, **„Supporter-
|
||||
Prämien & Versand verwalten"** und **„Supporter-Kollektionen verwalten"**
|
||||
zuweisen (Gruppe „Supporter-Abo" in der Rechteverwaltung).
|
||||
|
||||
### Verbindliche Prämienlogik (nicht verändern, Abschnitt 11-13 des Auftrags)
|
||||
|
||||
| Erfolgreich bezahlte Monate (im laufenden Zyklus) | Prämie |
|
||||
|---|---|
|
||||
| 6 | Kleiner Hasen-Teddy |
|
||||
| 12 | Mittelgroßer Hasen-Teddy |
|
||||
| 18 | Großer Hasen-Teddy |
|
||||
| 24 | Tasse & Autogrammkarte |
|
||||
| 30 | Exklusiver Hoodie — Zyklus abgeschlossen |
|
||||
| 31+ | Neuer 30-Monats-Zyklus, neue Kollektion, Fortschritt bei 0 (Gesamt-Mitgliedschaftsdauer bleibt erhalten) |
|
||||
|
||||
Testzahlungen (Sandbox) zählen **nie** als echter Monat. Jedes
|
||||
PayPal-Ereignis wird per `paypal_event_id` genau einmal verarbeitet
|
||||
(keine doppelte Anrechnung bei erneuter Zustellung).
|
||||
|
||||
### Bewusst noch nicht gebaut (nächste Ausbaustufe, nicht Teil der Abnahmekriterien)
|
||||
|
||||
Abschnitt 20 des Auftrags ("Zusätzliche Vorteile") beschreibt optionale
|
||||
Bonus-Features — Abstimmungen, Downloads, Supporter-Wand, Jubiläums-
|
||||
Videobotschaften. Die Datenbankstruktur lässt Raum dafür, sie sind aber
|
||||
bewusst **nicht** Teil dieser ersten Ausbaustufe, da sie in den
|
||||
"Verbindlichen Abnahmekriterien" (Abschnitt 34) nicht gefordert sind.
|
||||
|
||||
## Wartung
|
||||
|
||||
- Bewerbungen/Zugänge/Rollen: alles über `zugaenge.html` bzw. `postfach.html` als Owner.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
iRF6hbEQmRMyie
|
||||
@@ -0,0 +1,152 @@
|
||||
-- =====================================================================
|
||||
-- DOGFATHER × VanVan Supporter-Abo — Datenbankschema
|
||||
-- Setzt "Dogfather_VanVan_Supporter_Abo.odt" um (Abschnitt 28 empfiehlt
|
||||
-- diese Struktur; hier leicht angepasst an die Konventionen des restlichen
|
||||
-- Workers: TEXT-IDs, ISO-Zeitstempel, Wiederverwendung von audit_log statt
|
||||
-- eines eigenen Änderungsprotokolls).
|
||||
--
|
||||
-- Bewusst eine EIGENE, von der internen Team-Zugangsverwaltung (users/
|
||||
-- roles/sessions) komplett GETRENNTE Nutzerwelt — Supporter sind
|
||||
-- öffentliche Fans/Kund:innen, keine Teammitglieder mit Rechten.
|
||||
-- =====================================================================
|
||||
|
||||
-- Öffentliche Supporter-Accounts. Nur Name, TikTok-Name, E-Mail bei
|
||||
-- Registrierung (siehe Abschnitt 5/30 — Datensparsamkeit).
|
||||
CREATE TABLE IF NOT EXISTS supporter_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
tiktok_username TEXT, -- ohne "@", normalisiert
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
pending_email TEXT, -- neue E-Mail, bis sie bestätigt wird (Abschnitt 25)
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active | deleted
|
||||
-- Versanddaten werden ERST bei der ersten Prämien-Einlösung abgefragt
|
||||
-- (Abschnitt 17/30), deshalb hier bewusst alles NULL-fähig.
|
||||
ship_name TEXT,
|
||||
ship_street TEXT,
|
||||
ship_zip TEXT,
|
||||
ship_city TEXT,
|
||||
ship_country TEXT,
|
||||
ship_save_address INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
last_login_at TEXT
|
||||
);
|
||||
|
||||
-- Einmalcodes für E-Mail-Bestätigung UND Magic-Link-Login (Abschnitt 5/116).
|
||||
CREATE TABLE IF NOT EXISTS supporter_auth_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
supporter_id TEXT NOT NULL REFERENCES supporter_users(id),
|
||||
purpose TEXT NOT NULL, -- verify_email | login
|
||||
code_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Sessions für den öffentlichen Supporter-Bereich — eigenes, von der
|
||||
-- internen Team-Session-Tabelle getrenntes Token-System.
|
||||
CREATE TABLE IF NOT EXISTS supporter_sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
supporter_id TEXT NOT NULL REFERENCES supporter_users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
last_active_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Eine (aktuell: genau eine) Abo-Stufe pro Supporter.
|
||||
CREATE TABLE IF NOT EXISTS supporter_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
supporter_id TEXT NOT NULL REFERENCES supporter_users(id),
|
||||
paypal_subscription_id TEXT UNIQUE, -- erst nach PayPal-Freigabe gesetzt
|
||||
paypal_plan_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | active | suspended | cancelled | expired
|
||||
started_at TEXT,
|
||||
next_payment_at TEXT,
|
||||
paid_period_end_at TEXT, -- bis wann Zugang nach Kündigung bleibt
|
||||
cancelled_at TEXT,
|
||||
-- Fortschritt (Abschnitt 11-13): Gesamtzahl erfolgreich angerechneter
|
||||
-- Monate bleibt für IMMER erhalten (auch über Kündigung/Reaktivierung
|
||||
-- hinweg, Abschnitt 23) — daraus werden Zyklus/Meilenstein/Kollektion
|
||||
-- rein rechnerisch abgeleitet (siehe lib/supporter-cycle.js), NICHT
|
||||
-- redundant gespeichert, damit nichts auseinanderlaufen kann.
|
||||
total_paid_months INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Jede EINZELNE erfolgreiche/fehlgeschlagene PayPal-Zahlung. Idempotenz
|
||||
-- über paypal_event_id: jedes Webhook-Ereignis wird nur einmal verarbeitet
|
||||
-- (Abschnitt 8, "doppelt empfangenes PayPal-Ereignis: keine doppelte
|
||||
-- Anrechnung").
|
||||
CREATE TABLE IF NOT EXISTS supporter_payments (
|
||||
id TEXT PRIMARY KEY,
|
||||
supporter_id TEXT NOT NULL REFERENCES supporter_users(id),
|
||||
subscription_id TEXT NOT NULL REFERENCES supporter_subscriptions(id),
|
||||
paypal_transaction_id TEXT,
|
||||
paypal_event_id TEXT UNIQUE,
|
||||
amount TEXT,
|
||||
currency TEXT DEFAULT 'EUR',
|
||||
payment_status TEXT NOT NULL, -- completed | failed | refunded | reversed
|
||||
is_sandbox INTEGER NOT NULL DEFAULT 0, -- Testzahlungen zählen NIE als echter Monat
|
||||
counted_for_progress INTEGER NOT NULL DEFAULT 0,
|
||||
paid_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Supporter-Kollektionen (Abschnitt 14) — jeder abgeschlossene 30-Monats-
|
||||
-- Zyklus bekommt ein neues Design. Zyklus-Nummer 1 = Kollektion 01 usw.
|
||||
-- collection_number ist der 1-basierte Zyklus-Index (global, nicht pro
|
||||
-- Supporter — alle Supporter im selben Zyklus teilen sich EIN Design).
|
||||
CREATE TABLE IF NOT EXISTS supporter_collections (
|
||||
id TEXT PRIMARY KEY,
|
||||
collection_number INTEGER NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
theme TEXT,
|
||||
description TEXT,
|
||||
cover_image TEXT,
|
||||
small_teddy_design TEXT,
|
||||
medium_teddy_design TEXT,
|
||||
large_teddy_design TEXT,
|
||||
mug_design TEXT,
|
||||
card_design TEXT,
|
||||
hoodie_design TEXT,
|
||||
available_hoodie_sizes TEXT DEFAULT '["S","M","L","XL","XXL"]', -- JSON-Array
|
||||
status TEXT NOT NULL DEFAULT 'draft', -- draft | published
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Eine Zeile pro freigeschaltetem Meilenstein/Prämie eines Supporters.
|
||||
-- milestone_month ∈ {6,12,18,24,30}, cycle_number = welcher 30er-Zyklus.
|
||||
CREATE TABLE IF NOT EXISTS supporter_premiums (
|
||||
id TEXT PRIMARY KEY,
|
||||
supporter_id TEXT NOT NULL REFERENCES supporter_users(id),
|
||||
collection_id TEXT NOT NULL REFERENCES supporter_collections(id),
|
||||
cycle_number INTEGER NOT NULL,
|
||||
milestone_month INTEGER NOT NULL, -- 6 | 12 | 18 | 24 | 30
|
||||
premium_type TEXT NOT NULL, -- teddy_klein | teddy_mittel | teddy_gross | tasse_karte | hoodie
|
||||
unlocked_at TEXT NOT NULL,
|
||||
redeem_status TEXT NOT NULL DEFAULT 'verfuegbar',
|
||||
-- verfuegbar | eingeloest | wird_geprueft | wird_vorbereitet
|
||||
-- versandbereit | versendet | zugestellt | erhalten | problem | ersatz_wird_vorbereitet
|
||||
redeemed_at TEXT,
|
||||
hoodie_size TEXT,
|
||||
hoodie_variant TEXT,
|
||||
ship_name TEXT, ship_street TEXT, ship_zip TEXT, ship_city TEXT, ship_country TEXT,
|
||||
tracking_number TEXT,
|
||||
shipped_at TEXT,
|
||||
delivered_at TEXT,
|
||||
admin_note TEXT,
|
||||
UNIQUE (supporter_id, cycle_number, milestone_month)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supp_sub_supporter ON supporter_subscriptions(supporter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supp_pay_supporter ON supporter_payments(supporter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supp_pay_sub ON supporter_payments(subscription_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supp_premiums_supporter ON supporter_premiums(supporter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supp_sessions_supporter ON supporter_sessions(supporter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supp_codes_supporter ON supporter_auth_codes(supporter_id);
|
||||
|
||||
-- Kollektion 01 direkt mit anlegen, damit das System sofort nutzbar ist —
|
||||
-- Designs/Bilder können jederzeit im Admin-Bereich nachgepflegt werden.
|
||||
INSERT OR IGNORE INTO supporter_collections (id, collection_number, name, theme, description, status, created_at)
|
||||
VALUES ('collection-01', 1, 'Der Anfang', 'Babyblau & Silber', 'Die allererste Supporter-Kollektion von Dogfather × VanVan.', 'published', datetime('now'));
|
||||
@@ -0,0 +1,22 @@
|
||||
-- =====================================================================
|
||||
-- Supporter-Login: TikTok & Discord (Nutzer-Wunsch 03.08.2026 — "professionelle
|
||||
-- Art sich anzumelden", kostenlose Alternativen zu "Mit Apple anmelden"
|
||||
-- (99 $/Jahr, verstößt gegen die 0-€-Vorgabe)).
|
||||
--
|
||||
-- Google-Login (bereits vorhanden) matched Konten rein über die E-Mail
|
||||
-- (email NOT NULL UNIQUE) — funktioniert, weil Google IMMER eine
|
||||
-- bestätigte E-Mail liefert. TikTok Login Kit liefert dagegen in aller
|
||||
-- Regel GAR KEINE E-Mail (nur open_id/union_id/display_name), Discord
|
||||
-- liefert zwar eine E-Mail, aber zur klaren Nachverfolgbarkeit ("mit
|
||||
-- welchem Anbieter wurde dieses Konto angelegt/zuletzt genutzt") bekommt
|
||||
-- auch Discord eine eigene ID-Spalte statt sich nur auf E-Mail-Matching
|
||||
-- zu verlassen.
|
||||
-- =====================================================================
|
||||
|
||||
ALTER TABLE supporter_users ADD COLUMN tiktok_open_id TEXT;
|
||||
ALTER TABLE supporter_users ADD COLUMN discord_id TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_supporter_users_tiktok_open_id
|
||||
ON supporter_users (tiktok_open_id) WHERE tiktok_open_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_supporter_users_discord_id
|
||||
ON supporter_users (discord_id) WHERE discord_id IS NOT NULL;
|
||||
@@ -7,16 +7,19 @@
|
||||
- Team-Codes: SHA-256-Hash-Lookup (users.code_hash).
|
||||
- Nach 5 Fehlversuchen pro Client (IP-basiert) 15 Minuten Sperre —
|
||||
angelehnt an Cloudflares "Protect your login"-Standardregel.
|
||||
- Sessions: Token wird nur gehasht gespeichert. Inaktivitäts-Timeout
|
||||
30 Minuten, absolute Höchstdauer 12 Stunden.
|
||||
- Sessions: Token wird nur gehasht gespeichert. Inaktivitäts-Timeout und
|
||||
absolute Höchstdauer beide 24 Stunden (Nutzer-Wunsch 04.08.2026: "den
|
||||
Zugangscode auf der Verwaltungsseite nur einmal am Tag eingeben
|
||||
müssen") — vorher 30 Min/12 Std, was mitten am Tag zum erneuten
|
||||
Login-Zwang führte.
|
||||
===================================================================== */
|
||||
import { sha256Hex, generateSessionToken, nowIso } from "./crypto.js";
|
||||
import { logAction } from "./audit.js";
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCKOUT_MINUTES = 15;
|
||||
const INACTIVITY_MS = 30 * 60 * 1000;
|
||||
const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000;
|
||||
const INACTIVITY_MS = 24 * 60 * 60 * 1000;
|
||||
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function clientKey(request) {
|
||||
return request.headers.get("CF-Connecting-IP") || "unknown";
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/* =====================================================================
|
||||
lib/google-auth.js — Verifiziert ein Google-ID-Token ("Mit Google
|
||||
anmelden") serverseitig, für den öffentlichen Supporter-Login.
|
||||
|
||||
WICHTIG — offener Punkt, siehe README.md "Supporter-Abo Setup":
|
||||
Braucht GOOGLE_CLIENT_ID (öffentlich, KEIN Geheimnis — steht bewusst als
|
||||
normale [vars]-Variable in wrangler.toml, nicht als Secret). Ohne
|
||||
gesetzten Wert bleibt "Mit Google anmelden" ausgeblendet (siehe
|
||||
supporter.js im Frontend) statt kaputt zu sein.
|
||||
|
||||
Verifikation läuft über Googles offiziellen tokeninfo-Endpunkt
|
||||
(https://oauth2.googleapis.com/tokeninfo) — Google prüft dort Signatur,
|
||||
Ablaufzeit und Aussteller selbst und gibt die Nutzdaten zurück. Das
|
||||
spart eine eigene JWKS-/RS256-Prüfung im Worker, ist aber genauso sicher
|
||||
(offiziell von Google für Server-Side-Verifikation empfohlen).
|
||||
===================================================================== */
|
||||
|
||||
export class GoogleAuthNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super("Google-Login ist noch nicht eingerichtet (GOOGLE_CLIENT_ID fehlt).");
|
||||
this.name = "GoogleAuthNotConfiguredError";
|
||||
this.code = "GOOGLE_AUTH_NOT_CONFIGURED";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ein von Google Identity Services im Browser ausgestelltes ID-Token.
|
||||
* Gibt bei Erfolg die bestätigten Nutzerdaten zurück (E-Mail ist von Google
|
||||
* selbst bereits verifiziert — braucht deshalb KEINEN zusätzlichen
|
||||
* E-Mail-Bestätigungscode mehr, siehe routes/supporter.js).
|
||||
*/
|
||||
export async function verifyGoogleIdToken(env, credential) {
|
||||
if (!env.GOOGLE_CLIENT_ID) throw new GoogleAuthNotConfiguredError();
|
||||
if (!credential) return { ok: false, reason: "Kein Token übergeben." };
|
||||
|
||||
const res = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`);
|
||||
if (!res.ok) return { ok: false, reason: "Google-Token konnte nicht geprüft werden." };
|
||||
const data = await res.json();
|
||||
|
||||
if (data.aud !== env.GOOGLE_CLIENT_ID) return { ok: false, reason: "Token ist für eine andere App ausgestellt." };
|
||||
if (!data.email || data.email_verified !== "true") return { ok: false, reason: "E-Mail-Adresse ist bei Google nicht bestätigt." };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
email: String(data.email).toLowerCase(),
|
||||
name: data.name || data.given_name || data.email.split("@")[0],
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,16 @@
|
||||
http.js — gemeinsame HTTP-Helfer (CORS, JSON-Responses)
|
||||
===================================================================== */
|
||||
|
||||
// TODO (bewusst noch NICHT auf eine feste Origin eingeschränkt): seit
|
||||
// 03.08.2026 ist die eigene Domain final (dogfather-universe.com, läuft
|
||||
// PARALLEL zur bisherigen workers.dev-Adresse weiter). Ein Origin-genaues
|
||||
// CORS bräuchte den `request` (für den Origin-Header) an praktisch jeder
|
||||
// `json(...)`-Aufrufstelle im gesamten Worker — ein größerer, eigener
|
||||
// Umbau, absichtlich nicht "schnell nebenbei" gemacht, um keine
|
||||
// Login-/Zahlungs-Endpunkte durch eine halbfertige Änderung zu brechen.
|
||||
// Erlaubte Origins für den künftigen Umbau: https://dogfather-universe.com,
|
||||
// https://www.dogfather-universe.com, https://dogfather-universe.dogfather1608.workers.dev
|
||||
export function corsHeaders() {
|
||||
// TODO: sobald die Domain final steht, statt "*" die echte Domain eintragen.
|
||||
return {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/* =====================================================================
|
||||
lib/paypal.js — PayPal Subscriptions API Anbindung
|
||||
(Dogfather_VanVan_Supporter_Abo.odt, Abschnitt 7/8)
|
||||
|
||||
WICHTIG — offener Punkt, siehe README.md "Supporter-Abo Setup":
|
||||
Diese Datei ist vollständig fertig implementiert (OAuth2, Subscription
|
||||
erstellen, Subscription abfragen, Webhook-Signatur prüfen), kann aber
|
||||
erst wirklich Geld verarbeiten, sobald folgende Secrets gesetzt sind
|
||||
(per `wrangler secret put NAME`, NIEMALS im Code/Repo):
|
||||
PAYPAL_CLIENT_ID
|
||||
PAYPAL_CLIENT_SECRET
|
||||
PAYPAL_PLAN_ID (aus dem PayPal-Business-Konto, Abschnitt 7)
|
||||
PAYPAL_WEBHOOK_ID
|
||||
Und der Var (in wrangler.toml, unkritisch, kein Secret):
|
||||
PAYPAL_ENV = "sandbox" | "live"
|
||||
|
||||
Diese Werte kann NUR der Website-Betreiber selbst erzeugen (PayPal-
|
||||
Business-Konto + Developer-App sind an seine eigene Identität/Bank
|
||||
gebunden) — deshalb "fail closed" mit sprechendem Fehler statt eines
|
||||
Absturzes, solange sie fehlen.
|
||||
===================================================================== */
|
||||
|
||||
export class PayPalNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super("PayPal ist noch nicht eingerichtet (PAYPAL_CLIENT_ID/SECRET/PLAN_ID fehlen).");
|
||||
this.name = "PayPalNotConfiguredError";
|
||||
this.code = "PAYPAL_NOT_CONFIGURED";
|
||||
}
|
||||
}
|
||||
|
||||
function baseUrl(env) {
|
||||
return env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
|
||||
}
|
||||
|
||||
function assertConfigured(env) {
|
||||
if (!env.PAYPAL_CLIENT_ID || !env.PAYPAL_CLIENT_SECRET || !env.PAYPAL_PLAN_ID) {
|
||||
throw new PayPalNotConfiguredError();
|
||||
}
|
||||
}
|
||||
|
||||
let cachedToken = null; // { value, expiresAt } — pro Worker-Instanz, spart Requests
|
||||
|
||||
async function getAccessToken(env) {
|
||||
assertConfigured(env);
|
||||
if (cachedToken && cachedToken.expiresAt > Date.now() + 30_000) return cachedToken.value;
|
||||
|
||||
const res = await fetch(`${baseUrl(env)}/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${btoa(`${env.PAYPAL_CLIENT_ID}:${env.PAYPAL_CLIENT_SECRET}`)}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
if (!res.ok) throw new Error(`PayPal-OAuth fehlgeschlagen (${res.status}): ${await res.text().catch(() => "")}`);
|
||||
const data = await res.json();
|
||||
cachedToken = { value: data.access_token, expiresAt: Date.now() + (data.expires_in || 3600) * 1000 };
|
||||
return cachedToken.value;
|
||||
}
|
||||
|
||||
async function paypalFetch(env, path, options = {}) {
|
||||
const token = await getAccessToken(env);
|
||||
const res = await fetch(`${baseUrl(env)}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) {
|
||||
const err = new Error(`PayPal-Anfrage fehlgeschlagen (${res.status}): ${text.slice(0, 400)}`);
|
||||
err.paypalStatus = res.status;
|
||||
err.paypalBody = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein PayPal-Abonnement für einen Supporter und liefert den
|
||||
* "approve"-Link zurück, zu dem der Nutzer weitergeleitet werden muss, um
|
||||
* das Abo direkt bei PayPal zu bestätigen (Abschnitt 6, Schritt 6-7).
|
||||
* custom_id trägt unsere interne supporter_id, damit der Webhook später
|
||||
* weiß, wem die Zahlung gehört.
|
||||
*/
|
||||
export async function createSubscription(env, { supporterId, returnUrl, cancelUrl }) {
|
||||
const data = await paypalFetch(env, "/v1/billing/subscriptions", {
|
||||
method: "POST",
|
||||
headers: { "PayPal-Request-Id": `sub-${supporterId}-${Date.now()}` },
|
||||
body: JSON.stringify({
|
||||
plan_id: env.PAYPAL_PLAN_ID,
|
||||
custom_id: supporterId,
|
||||
application_context: {
|
||||
brand_name: "Dogfather",
|
||||
locale: "de-DE",
|
||||
shipping_preference: "NO_SHIPPING",
|
||||
user_action: "SUBSCRIBE_NOW",
|
||||
return_url: returnUrl,
|
||||
cancel_url: cancelUrl,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const approveLink = (data.links || []).find((l) => l.rel === "approve");
|
||||
return { paypalSubscriptionId: data.id, approveUrl: approveLink?.href || null, raw: data };
|
||||
}
|
||||
|
||||
export async function getSubscription(env, paypalSubscriptionId) {
|
||||
return paypalFetch(env, `/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}`);
|
||||
}
|
||||
|
||||
/** Kündigung (Abschnitt 22: "verständlich und leicht kündbar"). Zugang bleibt bis Periodenende bestehen. */
|
||||
export async function cancelSubscription(env, paypalSubscriptionId, reason) {
|
||||
await paypalFetch(env, `/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}/cancel`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason: reason || "Kündigung durch Supporter" }),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Echtheit eines eingehenden Webhooks über PayPals offizielle
|
||||
* Verify-Signature-Funktion (Abschnitt 8, "sichere PayPal-Webhook-Prüfung"
|
||||
* aus den Sicherheitsanforderungen, Abschnitt 29).
|
||||
*/
|
||||
export async function verifyWebhookSignature(env, request, rawBody) {
|
||||
assertConfigured(env);
|
||||
if (!env.PAYPAL_WEBHOOK_ID) throw new PayPalNotConfiguredError();
|
||||
|
||||
const h = request.headers;
|
||||
const verification = await paypalFetch(env, "/v1/notifications/verify-webhook-signature", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
auth_algo: h.get("paypal-auth-algo"),
|
||||
cert_url: h.get("paypal-cert-url"),
|
||||
transmission_id: h.get("paypal-transmission-id"),
|
||||
transmission_sig: h.get("paypal-transmission-sig"),
|
||||
transmission_time: h.get("paypal-transmission-time"),
|
||||
webhook_id: env.PAYPAL_WEBHOOK_ID,
|
||||
webhook_event: JSON.parse(rawBody),
|
||||
}),
|
||||
});
|
||||
return verification.verification_status === "SUCCESS";
|
||||
}
|
||||
@@ -56,6 +56,11 @@ export const PERMISSIONS = {
|
||||
SETTINGS_VIEW_STATS: "Statistiken ansehen",
|
||||
SETTINGS_VIEW_ACTIVITY_LOG: "Aktivitätsverlauf ansehen",
|
||||
SETTINGS_EDIT_GENERAL: "Allgemeine Einstellungen bearbeiten",
|
||||
|
||||
// Supporter-Abo (Dogfather; die Teddy-Kollektion entsteht in Kooperation mit VanVan)
|
||||
SUPPORTER_VIEW: "Supporter-Abonnenten ansehen",
|
||||
SUPPORTER_MANAGE_PREMIUMS: "Supporter-Prämien & Versand verwalten",
|
||||
SUPPORTER_MANAGE_COLLECTIONS: "Supporter-Kollektionen verwalten",
|
||||
};
|
||||
|
||||
export const PERMISSION_GROUPS = [
|
||||
@@ -65,6 +70,7 @@ export const PERMISSION_GROUPS = [
|
||||
{ label: "Interne Notizen", keys: ["NOTES_READ", "NOTES_WRITE", "NOTES_EDIT_OWN", "NOTES_EDIT_OTHERS", "NOTES_DELETE"] },
|
||||
{ label: "Teamverwaltung", keys: ["TEAM_VIEW", "TEAM_PREPARE_USER", "TEAM_LOCK_USER", "TEAM_ASSIGN_ROLE", "TEAM_CREATE_ROLE", "TEAM_EDIT_PERMISSIONS"] },
|
||||
{ label: "Einstellungen", keys: ["SETTINGS_EDIT_FORMS", "SETTINGS_EDIT_STATUS_TYPES", "SETTINGS_VIEW_STATS", "SETTINGS_VIEW_ACTIVITY_LOG", "SETTINGS_EDIT_GENERAL"] },
|
||||
{ label: "DogiCrew 🩵", keys: ["SUPPORTER_VIEW", "SUPPORTER_MANAGE_PREMIUMS", "SUPPORTER_MANAGE_COLLECTIONS"] },
|
||||
];
|
||||
|
||||
// Nur zur Dokumentation/Klarheit — diese Aktionen sind NIE als Permission
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/* =====================================================================
|
||||
lib/supporter-auth.js — Registrierung, E-Mail-Bestätigung, Magic-Link-
|
||||
Login und Sessions für den ÖFFENTLICHEN Supporter-Bereich.
|
||||
|
||||
Bewusst komplett getrennt von auth.js (das ist für das interne
|
||||
Team-Zugangssystem mit Rollen/Rechten). Supporter sind Fans/Kund:innen
|
||||
ohne jegliche Berechtigungen im internen Bereich.
|
||||
|
||||
Login-Prinzip (Abschnitt 5/116 im Auftrag: "bevorzugt … sicherer
|
||||
E-Mail-Code oder Magic Link"): kein Passwort nötig. Ein 6-stelliger Code
|
||||
wird an die hinterlegte E-Mail-Adresse geschickt, 15 Minuten gültig,
|
||||
einmal verwendbar, nur der Hash liegt in der Datenbank.
|
||||
===================================================================== */
|
||||
import { sha256Hex, generateSessionToken, generateId, nowIso } from "./crypto.js";
|
||||
|
||||
const CODE_TTL_MS = 15 * 60 * 1000;
|
||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 Tage — Supporter sollen nicht ständig neu einloggen müssen
|
||||
const INACTIVITY_MS = 14 * 24 * 60 * 60 * 1000; // 14 Tage ohne Aktivität → Session verfällt
|
||||
|
||||
export function normalizeTikTokUsername(input) {
|
||||
if (!input) return null;
|
||||
return String(input).trim().replace(/^@+/, "").toLowerCase() || null;
|
||||
}
|
||||
|
||||
export function normalizeEmail(input) {
|
||||
return String(input || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function generateNumericCode() {
|
||||
const n = crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000;
|
||||
return String(n).padStart(6, "0");
|
||||
}
|
||||
|
||||
/** Erzeugt+speichert einen neuen 6-stelligen Code für E-Mail-Bestätigung oder Login. */
|
||||
export async function issueAuthCode(env, supporterId, purpose) {
|
||||
const code = generateNumericCode();
|
||||
const codeHash = await sha256Hex(code);
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO supporter_auth_codes (id, supporter_id, purpose, code_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(generateId(), supporterId, purpose, codeHash, new Date(Date.now() + CODE_TTL_MS).toISOString(), nowIso())
|
||||
.run();
|
||||
return code;
|
||||
}
|
||||
|
||||
/** Prüft einen eingegebenen Code; markiert ihn bei Erfolg als verbraucht (einmal gültig). */
|
||||
export async function verifyAuthCode(env, supporterId, purpose, code) {
|
||||
const codeHash = await sha256Hex(String(code || "").trim());
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT id, expires_at, used_at FROM supporter_auth_codes
|
||||
WHERE supporter_id = ? AND purpose = ? AND code_hash = ?
|
||||
ORDER BY created_at DESC LIMIT 1`
|
||||
)
|
||||
.bind(supporterId, purpose, codeHash)
|
||||
.first();
|
||||
if (!row) return false;
|
||||
if (row.used_at) return false;
|
||||
if (new Date(row.expires_at).getTime() < Date.now()) return false;
|
||||
await env.DB.prepare(`UPDATE supporter_auth_codes SET used_at = ? WHERE id = ?`).bind(nowIso(), row.id).run();
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function createSupporterSession(env, supporterId) {
|
||||
const token = generateSessionToken();
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const now = new Date();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO supporter_sessions (token_hash, supporter_id, created_at, last_active_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(tokenHash, supporterId, now.toISOString(), now.toISOString(), new Date(now.getTime() + SESSION_MAX_AGE_MS).toISOString())
|
||||
.run();
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function resolveSupporterSession(env, token) {
|
||||
if (!token) return null;
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const session = await env.DB.prepare(`SELECT * FROM supporter_sessions WHERE token_hash = ?`).bind(tokenHash).first();
|
||||
if (!session) return null;
|
||||
|
||||
const now = Date.now();
|
||||
if (new Date(session.expires_at).getTime() < now || now - new Date(session.last_active_at).getTime() > INACTIVITY_MS) {
|
||||
await env.DB.prepare(`DELETE FROM supporter_sessions WHERE token_hash = ?`).bind(tokenHash).run();
|
||||
return null;
|
||||
}
|
||||
await env.DB.prepare(`UPDATE supporter_sessions SET last_active_at = ? WHERE token_hash = ?`).bind(nowIso(), tokenHash).run();
|
||||
|
||||
const supporter = await env.DB.prepare(
|
||||
`SELECT * FROM supporter_users WHERE id = ? AND status = 'active'`
|
||||
)
|
||||
.bind(session.supporter_id)
|
||||
.first();
|
||||
if (!supporter) return null;
|
||||
return supporter;
|
||||
}
|
||||
|
||||
export async function endSupporterSession(env, token) {
|
||||
if (!token) return;
|
||||
await env.DB.prepare(`DELETE FROM supporter_sessions WHERE token_hash = ?`).bind(await sha256Hex(token)).run();
|
||||
}
|
||||
|
||||
export async function requireSupporterSession(request, env) {
|
||||
const auth = request.headers.get("Authorization") || "";
|
||||
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
|
||||
return resolveSupporterSession(env, token);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/* =====================================================================
|
||||
lib/supporter-cycle.js — Reine Rechenlogik für den 30-Monats-
|
||||
Prämienzyklus des Dogfather Supporter-Abos (die Teddy-Kollektion
|
||||
entsteht in Kooperation mit VanVan, das Abo selbst gehört nur Dogfather).
|
||||
|
||||
Verbindliche Regeln (siehe Dogfather_VanVan_Supporter_Abo.odt,
|
||||
Abschnitt 11-13, wortwörtlich umgesetzt, NICHT verändert):
|
||||
Monat 6 → kleiner Hasen-Teddy
|
||||
Monat 12 → mittelgroßer Hasen-Teddy
|
||||
Monat 18 → großer Hasen-Teddy
|
||||
Monat 24 → Tasse + Autogrammkarte
|
||||
Monat 30 → Hoodie (Zyklus damit abgeschlossen)
|
||||
ab 31 → neuer Zyklus, neue Kollektion, Fortschritt bei 0
|
||||
|
||||
Reine Funktionen ohne D1-/Netzwerkzugriff — dadurch leicht von Hand
|
||||
nachrechenbar und ohne Testrunner überprüfbar (siehe Beispiel im
|
||||
Auftrag: 37 bezahlte Monate → Zyklus 2, Fortschritt 7 von 30).
|
||||
===================================================================== */
|
||||
|
||||
export const CYCLE_LENGTH = 30;
|
||||
|
||||
// Reihenfolge ist bewusst fest — bestimmt auch premium_type-Zuordnung.
|
||||
export const MILESTONES = [
|
||||
{ month: 6, type: "teddy_klein", labelDe: "Kleiner Hasen-Teddy" },
|
||||
{ month: 12, type: "teddy_mittel", labelDe: "Mittelgroßer Hasen-Teddy" },
|
||||
{ month: 18, type: "teddy_gross", labelDe: "Großer Hasen-Teddy" },
|
||||
{ month: 24, type: "tasse_karte", labelDe: "Tasse & Autogrammkarte" },
|
||||
{ month: 30, type: "hoodie", labelDe: "Exklusiver Hoodie" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Aus der GESAMTEN Anzahl erfolgreich angerechneter Abo-Monate (bleibt für
|
||||
* immer erhalten, auch über Kündigung/Reaktivierung hinweg) errechnet sich
|
||||
* rein rechnerisch: wie viele 30er-Zyklen sind vollständig abgeschlossen,
|
||||
* in welchem Zyklus befindet man sich gerade (1-basiert) und wie weit ist
|
||||
* der aktuelle Zyklus fortgeschritten (1..30).
|
||||
*/
|
||||
export function computeProgress(totalPaidMonths) {
|
||||
const total = Math.max(0, Math.floor(totalPaidMonths || 0));
|
||||
if (total === 0) {
|
||||
return { totalPaidMonths: 0, completedCycles: 0, cycleNumber: 1, currentCycleMonth: 0 };
|
||||
}
|
||||
const completedCycles = Math.floor((total - 1) / CYCLE_LENGTH);
|
||||
const currentCycleMonth = total - completedCycles * CYCLE_LENGTH; // 1..30
|
||||
return {
|
||||
totalPaidMonths: total,
|
||||
completedCycles,
|
||||
cycleNumber: completedCycles + 1, // 1-basiert = welche Kollektion gerade läuft
|
||||
currentCycleMonth,
|
||||
};
|
||||
}
|
||||
|
||||
/** Welche Meilensteine sind im AKTUELLEN, laufenden Zyklus schon erreicht? */
|
||||
export function reachedMilestonesInCurrentCycle(currentCycleMonth) {
|
||||
return MILESTONES.filter((m) => currentCycleMonth >= m.month);
|
||||
}
|
||||
|
||||
/** Nächster noch offener Meilenstein im laufenden Zyklus (oder null, wenn Zyklus fertig). */
|
||||
export function nextMilestone(currentCycleMonth) {
|
||||
return MILESTONES.find((m) => currentCycleMonth < m.month) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert ALLE (Zyklus, Meilenstein)-Kombinationen, die bei der gegebenen
|
||||
* Gesamtmonatszahl freigeschaltet sein MÜSSEN — über alle bereits
|
||||
* abgeschlossenen Zyklen hinweg plus den laufenden Zyklus. Wird beim
|
||||
* Verarbeiten einer erfolgreichen Zahlung benutzt, um herauszufinden,
|
||||
* welche supporter_premiums-Zeilen neu angelegt werden müssen.
|
||||
*/
|
||||
export function allUnlockedMilestones(totalPaidMonths) {
|
||||
const { completedCycles, currentCycleMonth } = computeProgress(totalPaidMonths);
|
||||
const unlocked = [];
|
||||
// Vollständig abgeschlossene Zyklen: ALLE 5 Meilensteine gelten als erreicht.
|
||||
for (let c = 1; c <= completedCycles; c++) {
|
||||
for (const m of MILESTONES) unlocked.push({ cycleNumber: c, ...m });
|
||||
}
|
||||
// Laufender Zyklus: nur die bereits erreichten.
|
||||
const cycleNumber = completedCycles + 1;
|
||||
for (const m of reachedMilestonesInCurrentCycle(currentCycleMonth)) {
|
||||
unlocked.push({ cycleNumber, ...m });
|
||||
}
|
||||
return unlocked;
|
||||
}
|
||||
|
||||
/** Für die Fortschritts-Zeitleiste im Supporter-Bereich (Abschnitt 10, Fenster 2). */
|
||||
export function timelineForCurrentCycle(totalPaidMonths) {
|
||||
const { currentCycleMonth, cycleNumber } = computeProgress(totalPaidMonths);
|
||||
return {
|
||||
cycleNumber,
|
||||
currentCycleMonth,
|
||||
milestones: MILESTONES.map((m) => ({
|
||||
...m,
|
||||
reached: currentCycleMonth >= m.month,
|
||||
isNext: nextMilestone(currentCycleMonth)?.month === m.month,
|
||||
monthsRemaining: Math.max(0, m.month - currentCycleMonth),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* =====================================================================
|
||||
lib/supporter-mail.js — Transaktions-E-Mails für den Supporter-Bereich
|
||||
(E-Mail-Bestätigung, Login-Codes, Zahlungs-/Prämien-Benachrichtigungen).
|
||||
|
||||
WICHTIG — offener Punkt, siehe auch README.md "Supporter-Abo Setup":
|
||||
Es existiert aktuell KEIN E-Mail-Versand-Anbieter (kein Resend/
|
||||
MailChannels o.ä. eingerichtet) und KEINE eigene Domain (die Seite läuft
|
||||
noch auf *.workers.dev) — beides wird für zuverlässigen E-Mail-Versand
|
||||
gebraucht (Absender-Domain muss per SPF/DKIM verifiziert sein, sonst
|
||||
landet alles im Spam oder wird abgelehnt).
|
||||
|
||||
Diese Datei ist deshalb bewusst als AUSTAUSCHBARER Adapter gebaut:
|
||||
Sobald RESEND_API_KEY (+ RESEND_FROM) als Secret gesetzt ist, wird über
|
||||
die Resend-API (https://resend.com, kostenloser Tier reicht für den
|
||||
Start) verschickt. Fehlt der Key, wird NICHTS verschickt und ein
|
||||
sprechender Fehler zurückgegeben — bewusst "fail closed" statt den Code
|
||||
irgendwo im Klartext zu loggen (Sicherheitsprinzip wie beim Rest des
|
||||
Zugangssystems, siehe audit.js).
|
||||
===================================================================== */
|
||||
|
||||
export class EmailNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super("E-Mail-Versand ist noch nicht eingerichtet (RESEND_API_KEY fehlt).");
|
||||
this.name = "EmailNotConfiguredError";
|
||||
this.code = "EMAIL_NOT_CONFIGURED";
|
||||
}
|
||||
}
|
||||
|
||||
async function send(env, { to, subject, html }) {
|
||||
if (!env.RESEND_API_KEY || !env.RESEND_FROM) throw new EmailNotConfiguredError();
|
||||
const res = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.RESEND_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ from: env.RESEND_FROM, to: [to], subject, html }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`E-Mail-Versand fehlgeschlagen (${res.status}): ${text.slice(0, 300)}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const WRAP = (title, bodyHtml) => `
|
||||
<div style="font-family:Arial,sans-serif;background:#0b0d10;color:#f3f5f7;padding:32px;">
|
||||
<div style="max-width:480px;margin:0 auto;background:#171b21;border-radius:14px;padding:28px;border:1px solid #262c35;">
|
||||
<h1 style="color:#8fd9ea;font-size:20px;margin:0 0 16px;">${title}</h1>
|
||||
${bodyHtml}
|
||||
<p style="color:#9aa4b2;font-size:12px;margin-top:24px;">DogiCrew 🩵</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export async function sendVerifyEmailCode(env, { to, name, code }) {
|
||||
return send(env, {
|
||||
to,
|
||||
subject: "Bestätige deine E-Mail-Adresse — DogiCrew 🩵",
|
||||
html: WRAP(
|
||||
`Willkommen, ${name}! 🩵`,
|
||||
`<p style="line-height:1.6;">Bestätige deine E-Mail-Adresse mit diesem Code:</p>
|
||||
<p style="font-size:32px;font-weight:800;letter-spacing:4px;color:#fff;">${code}</p>
|
||||
<p style="color:#9aa4b2;font-size:13px;">Der Code ist 15 Minuten gültig.</p>`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendLoginCode(env, { to, name, code }) {
|
||||
return send(env, {
|
||||
to,
|
||||
subject: "Dein Login-Code — Dogfather Supporter-Bereich",
|
||||
html: WRAP(
|
||||
`Hallo ${name} 👋`,
|
||||
`<p style="line-height:1.6;">Dein Login-Code für den Supporter-Bereich:</p>
|
||||
<p style="font-size:32px;font-weight:800;letter-spacing:4px;color:#fff;">${code}</p>
|
||||
<p style="color:#9aa4b2;font-size:13px;">Der Code ist 15 Minuten gültig. Hast du das nicht angefordert, ignoriere diese E-Mail.</p>`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendMilestoneUnlocked(env, { to, name, premiumLabel }) {
|
||||
return send(env, {
|
||||
to,
|
||||
subject: `Neuer Meilenstein freigeschaltet — ${premiumLabel} 🎉`,
|
||||
html: WRAP(
|
||||
`Glückwunsch, ${name}! 🩵`,
|
||||
`<p style="line-height:1.6;">Du hast einen neuen Meilenstein erreicht und kannst jetzt deine Prämie einlösen:</p>
|
||||
<p style="font-size:20px;font-weight:800;color:#fff;">${premiumLabel}</p>
|
||||
<p style="color:#9aa4b2;font-size:13px;">Logge dich in deinen Supporter-Bereich ein, um die Prämie einzulösen.</p>`
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -41,6 +41,19 @@ import {
|
||||
import { listAuditLog } from "./routes/audit.js";
|
||||
import { checkTikTokLive } from "./lib/tiktok-live.js";
|
||||
import { logAction } from "./lib/audit.js";
|
||||
import { requireSupporterSession } from "./lib/supporter-auth.js";
|
||||
import {
|
||||
registerSupporter, verifySupporterEmail, requestSupporterLogin, verifySupporterLogin, logoutSupporter,
|
||||
googleLoginSupporter, getPublicConfig,
|
||||
getSupporterMe, updateSupporterProfile, requestSupporterEmailChange, confirmSupporterEmailChange,
|
||||
redeemPremium, deleteSupporterAccount,
|
||||
} from "./routes/supporter.js";
|
||||
import {
|
||||
startPaypalSubscription, confirmPaypalSubscription, cancelPaypalSubscription, handlePaypalWebhook,
|
||||
} from "./routes/supporter-paypal.js";
|
||||
import {
|
||||
listSupporters, getSupporterDetail, updatePremium, listCollections, upsertCollection, getAdminOverview,
|
||||
} from "./routes/supporter-admin.js";
|
||||
|
||||
/* =====================================================================
|
||||
LIVE-STATUS
|
||||
@@ -225,6 +238,32 @@ const AUTHED_ROUTES = {
|
||||
"POST /admin/users/update-meta": (req, env, session) => updateUserMeta(req, env, session),
|
||||
|
||||
"POST /admin/audit/list": (req, env, session) => listAuditLog(req, env, session),
|
||||
|
||||
// Supporter-Abo — Administrationsbereich (interne Team-Session + eigene
|
||||
// SUPPORTER_*-Rechte, siehe permissions.js).
|
||||
"POST /admin/overview": (req, env, session) => getAdminOverview(req, env, session),
|
||||
"POST /admin/supporter/list": (req, env, session) => listSupporters(req, env, session),
|
||||
"POST /admin/supporter/detail": (req, env, session) => getSupporterDetail(req, env, session),
|
||||
"POST /admin/supporter/premium/update": (req, env, session) => updatePremium(req, env, session),
|
||||
"POST /admin/supporter/collections/list": (req, env, session) => listCollections(req, env, session),
|
||||
"POST /admin/supporter/collections/upsert": (req, env, session) => upsertCollection(req, env, session),
|
||||
};
|
||||
|
||||
// Routen des ÖFFENTLICHEN Supporter-Bereichs, die eine gültige
|
||||
// Supporter-Session brauchen (eigenes, von AUTHED_ROUTES komplett
|
||||
// getrenntes Token-System — siehe lib/supporter-auth.js). `supporter`
|
||||
// (die volle supporter_users-Zeile) wird injiziert statt `session`.
|
||||
const SUPPORTER_AUTHED_ROUTES = {
|
||||
"POST /supporter/logout": (req, env) => logoutSupporter(req, env),
|
||||
"POST /supporter/me": (req, env, supporter) => getSupporterMe(req, env, supporter),
|
||||
"POST /supporter/update-profile": (req, env, supporter) => updateSupporterProfile(req, env, supporter),
|
||||
"POST /supporter/request-email-change": (req, env, supporter) => requestSupporterEmailChange(req, env, supporter),
|
||||
"POST /supporter/confirm-email-change": (req, env, supporter) => confirmSupporterEmailChange(req, env, supporter),
|
||||
"POST /supporter/redeem-premium": (req, env, supporter) => redeemPremium(req, env, supporter),
|
||||
"POST /supporter/delete-account": (req, env, supporter) => deleteSupporterAccount(req, env, supporter),
|
||||
"POST /supporter/paypal/start": (req, env, supporter) => startPaypalSubscription(req, env, supporter),
|
||||
"POST /supporter/paypal/confirm": (req, env, supporter) => confirmPaypalSubscription(req, env, supporter),
|
||||
"POST /supporter/paypal/cancel": (req, env, supporter) => cancelPaypalSubscription(req, env, supporter),
|
||||
};
|
||||
|
||||
export default {
|
||||
@@ -249,6 +288,23 @@ export default {
|
||||
return login(request, env);
|
||||
}
|
||||
|
||||
// Supporter-Abo — öffentliche Routen ohne Session (Registrierung/Login).
|
||||
if (url.pathname === "/supporter/register" && request.method === "POST") return registerSupporter(request, env);
|
||||
if (url.pathname === "/supporter/verify-email" && request.method === "POST") return verifySupporterEmail(request, env);
|
||||
if (url.pathname === "/supporter/login-request" && request.method === "POST") return requestSupporterLogin(request, env);
|
||||
if (url.pathname === "/supporter/verify-login" && request.method === "POST") return verifySupporterLogin(request, env);
|
||||
if (url.pathname === "/supporter/google-login" && request.method === "POST") return googleLoginSupporter(request, env);
|
||||
if (url.pathname === "/supporter/public-config" && request.method === "GET") return getPublicConfig(request, env);
|
||||
// PayPal ruft diese Adresse direkt auf — eigene Signaturprüfung statt Session (siehe paypal.js).
|
||||
if (url.pathname === "/supporter/paypal-webhook" && request.method === "POST") return handlePaypalWebhook(request, env);
|
||||
|
||||
const supporterHandler = SUPPORTER_AUTHED_ROUTES[routeKey];
|
||||
if (supporterHandler) {
|
||||
const supporter = await requireSupporterSession(request, env);
|
||||
if (!supporter) return json({ ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut einloggen." }, 401);
|
||||
return supporterHandler(request, env, supporter);
|
||||
}
|
||||
|
||||
const handler = AUTHED_ROUTES[routeKey];
|
||||
if (handler) {
|
||||
const session = await requireSession(request, env);
|
||||
|
||||
@@ -11,6 +11,15 @@ compatibility_date = "2024-01-01"
|
||||
[vars]
|
||||
TIKTOK_USERNAME = "dogfather0804"
|
||||
|
||||
# Supporter-Abo (siehe README.md, Abschnitt "Supporter-Abo Setup").
|
||||
# Unkritische Konfiguration — die eigentlichen Geheimnisse/IDs (PAYPAL_CLIENT_ID/
|
||||
# SECRET, PAYPAL_PLAN_ID, PAYPAL_WEBHOOK_ID, RESEND_API_KEY, GOOGLE_CLIENT_ID)
|
||||
# werden NIE hier, sondern ausschließlich per `wrangler secret put` gesetzt —
|
||||
# auch GOOGLE_CLIENT_ID, obwohl er technisch nicht geheim ist (einfach für
|
||||
# einheitliches Setup, siehe README).
|
||||
PAYPAL_ENV = "sandbox"
|
||||
# RESEND_FROM = "Dogfather Team <[email protected]>" # erst setzen, sobald eine eigene Domain existiert
|
||||
|
||||
# Cron: jede Minute bei TikTok nachsehen, ob gerade gesendet wird.
|
||||
[triggers]
|
||||
crons = ["* * * * *"]
|
||||
|
||||
Reference in New Issue
Block a user