CASE · 092025 — LIVESOLO · END-TO-ENDFULL SAAS · FSRS · AI · STRIPE
◦ FLASHBUDDY · flashbuddy.app

Every card adaptive. Every streak earned.

Vollständige Lernplattform: FSRS-Spaced-Repetition, AI-Karteikarten und -Quizze aus PDFs/Notizen, Gamification mit XP/Streaks/Quests/Cosmetics, vier Stripe-Tiers und ein Klassenzimmer-Modus für Lehrer. Next.js 16 App Router · Prisma 6 · Postgres · Redis · Stripe · OpenAI · Soketi. Self-hosted via Coolify auf dediziertem Hetzner-Server hinter Cloudflare-Edge.

§ 01Problem · motivation

Why this exists.

Anki ist mächtig, aber roh. Quizlet ist hübsch, aber dumm. Beide ignorieren, dass Lernen ein Gewohnheits-Problem ist, kein UI-Problem.

Der etablierte Spaced-Repetition-Markt hat zwei Pole. Anki ist algorithmisch ehrlich — SM-2/FSRS, harte Defaults, jahrzehntelang gewachsen — aber das UI fühlt sich an wie Forschungssoftware aus 2008, und die Gamification ist bestenfalls nicht vorhanden. Quizlet hat den Look, aber der Lernalgorithmus ist eine glorifizierte Reihenfolge — kein FSRS, keine Memory-Stability-Modellierung, kein Anspruch, dich beim vergessen zu erwischen.

Flashbuddy schließt die Lücke: ein FSRS-Lernkern, der wissenschaftlich an Anki vorbeiläuft, eingebettet in ein Duolingo-artiges Gamification-Layer (XP, Streaks, Streak-Freezes, tägliche Quests, freischaltbare Cosmetics) und ein modernes, deutschsprachiges UI mit AI-Karteikarten- und Quiz-Generierung aus PDFs, Notizen oder freiem Text. Plus eine B2B-Schiene für Lehrer mit Klassenzimmern und Assignments — weil das eigentliche Geld in der Schule liegt, nicht bei Hobby-Lernern.

§ 02Constraints · operating box

The box it had to fit in.

Solo-Builder, Real-User-Traffic, DSGVO-pflichtig. Kein Vercel-Lock-in, keine VC-Burn-Rate, kein „nur wenn skaliert“-Gedanke.
C/01 · OPS
Solo-Dev. Jede Architekturentscheidung muss ohne Team wartbar sein — keine Microservices, keine Event-Bus-Choreografien, kein verteiltes Tracing-Bühnenbild.
C/02 · HOSTING
Coolify auf einem dedizierten Hetzner-Server — App, Postgres, Redis, Soketi laufen alle als Docker-Services unter einer Orchestrierung. Auto-Deploy bei push-to-main via Webhook, Cloudflare als Edge davor (SSL, Zero-Trust). Keine Cloud-Lock-Kosten, der ganze Stack kostet weniger als ein einziges AWS-RDS.
C/03 · DSGVO
Made in Germany, EU-Hosting, kompletter DSGVO-Datenexport über alle 28+ Prisma-Relationen, Account-Löschung mit harter Kaskade. PostHog auf EU-Endpoint.
C/04 · CREDITS
AI ist nicht gratis. Vier Tiers (FREE/STARTER/PREMIUM/PRO) mit monatlichem Credit-Reset und Top-up-Käufen. Pro Aktion 1–5 Credits, sauber abgerechnet — bevor der Call rausgeht.
C/05 · OFFLINE
PWA mit IndexedDB + Background-Sync. Pendler lernen in der U-Bahn — wer Reviews verliert, weil das WLAN flackert, kommt nicht zurück.
C/06 · SECURITY
User-generated Content, AI-generated Content, Stripe-Webhooks, API-Keys. CSRF (Header-Verify, keine Token), nonce-basierte CSP, Audit-Log mit DB-Trail, Sentry für alles, was throw sagt.
C/07 · REAL-TIME
29 Notification-Types — AI fertig, Freund-Anfrage, Challenge gewonnen, Achievement freigeschaltet. WebSocket via Soketi, Polling als Fallback, NotificationPreference pro User pro Kanal.
C/08 · I18N FLOOR
Komplett deutsch — keine englische Standard-Stringhalde, jede Fehlermeldung als ERR-Konstante in error-messages.ts. Eine englische Übersetzungsschicht, die weiterläuft, ohne dass Strings im Code stehen.
§ 03Architecture · request to review

How it runs.

Drei Spuren: oben der synchrone Request-Pfad (Browser → withApiHandler-Pipeline → Postgres), Mitte die AI-Spur (Credits-Gate → OpenAI → Generator → DB), unten die Side-Effects (Soketi-WebSocket, Stripe-Webhook, Sentry). Redis für Rate-Limit + Cache, alles auf einer einzigen Hetzner-Box, davor Cloudflare-Edge.
flashbuddy.app·host coolify·deploy push-to-main
tiers FREE / STARTER / PREMIUM / PRO·uptime 99.97%
CLIENT · 01
PWA · IndexedDB
Next.js 16 · service worker
offlinebg-sync
HANDLER · 02
withApiHandler
CSRF · Zod · rate · audit
latency142 ms
DATA · 03
Prisma 6 · Postgres 16
28+ relations · txn boundary
migrationsmanual SQL
EDGE · 04
Cloudflare · Traefik
SSL · Zero Trust · nonce CSP
orchestratorcoolify
GATE · 05
Credits · checkCredits()
pre-call · idempotency-key
cost1–5 cr
AI · 06
OpenAI router
nano · mini · vision
gpt-5-nanogpt-5-minivision
GEN · 07
Card · Quiz · PDF · OCR
cloze fallback · schema fix
FSRS · 08
Spaced repetition
per-user trained params
modelmemory-stable
CACHE · 09
Redis 7 · ioredis
sliding-window · upstash
limits100/20/5 rpm
PUSH · 10
Soketi · WebSocket
29 types · polling fallback
channelsprivate + presence
BILLING · 11
Stripe · 4 tiers + packs
idempotent webhooks · txn-safe
eventsdeduped by id
OBS · 12
Sentry · audit log
request-id · diff trail
tracex-request-id
EVENT LOG · /api/audit · request-id · diff trail
14:22:09openaigen.cards · 24 cloze · pdf-import · 2 cr · 1.84s
14:22:08stripewebhook · invoice.paid · STARTER → PREMIUM · deduped
14:22:07handlerPOST /api/decks · zod ok · csrf ok · 142ms
14:22:06soketiquest.completed · presence · 3 listeners · 8ms
14:22:04openaigen.quiz · 8 mc · history-101 · 2 cr · 0.91s
14:22:02handlerGET /api/decks/42/cards · 200 · 38ms
14:22:01redisrate.ok · user:1247 · 12/100 · sliding 60s
14:21:59fsrsschedule · 14 due · interval 3.2d → 6.1d
14:22:09openaigen.cards · 24 cloze · pdf-import · 2 cr · 1.84s
14:22:08stripewebhook · invoice.paid · STARTER → PREMIUM · deduped
14:22:07handlerPOST /api/decks · zod ok · csrf ok · 142ms
14:22:06soketiquest.completed · presence · 3 listeners · 8ms
14:22:04openaigen.quiz · 8 mc · history-101 · 2 cr · 0.91s
14:22:02handlerGET /api/decks/42/cards · 200 · 38ms
14:22:01redisrate.ok · user:1247 · 12/100 · sliding 60s
14:21:59fsrsschedule · 14 due · interval 3.2d → 6.1d
§ 04Decisions · trade-offs

Five deliberate choices.

Pro Entscheidung: was gewählt, statt was, warum.
D/01

Ein zentraler withApiHandler statt Per-Route-Boilerplate.

chosen
Generischer withApiHandler<T>(handler, opts, schema) macht in einer Funktion: Auth, Zod-Validation, CSRF-Verify, Rate-Limit, Body-Size-Cap, Ban-Check, Audit-Log, Sentry-Capture, Request-ID-Propagation.
instead of
Pro Route 30 Zeilen Boilerplate für dieselbe Sicherheitskette
reason
Bei 200+ API-Routes ist Copy-Paste-Sicherheit eine Frage von wann, nicht ob. Eine vergessene CSRF-Validation oder ein fehlender Rate-Limit pro Route ist die exakte Klasse von Bug, die aus einem Audit-Log rückwirkend nicht mehr fixbar ist. Ein zentraler Handler erzwingt die Defaults: Auth ist opt-out, nicht opt-in. Tests mocken eine einzige Surface, Error-Handling ist konsistent, neue Cross-Cutting-Concerns (Audit-Log kam später dazu) landen an einer einzigen Stelle. Die Abstraktion zahlt sich ab Route 4 aus, danach ist sie reine Schwerkraft.
D/02

Credits vor dem AI-Call, nicht danach.

chosen
checkCredits() → AI-Call → deductCredits() innerhalb derselben Prisma-Transaktion mit Idempotenz-Key
instead of
Optimistisches Abziehen post-hoc oder Soft-Limits aus Logs
reason
OpenAI kostet echtes Geld pro Request. Ein User auf FREE (20 Credits/Monat) darf bei einer leeren Wallet keinen gpt-5-mini-Vision-Call mehr triggern — selbst nicht, wenn zwei Tabs parallel klicken. Pre-Check + Transactional Deduct mit Idempotenz-Key fängt Race-Conditions ab, bei denen zwei Requests gleichzeitig „checken, abziehen" und beide den Call erlauben. Die Transaktion gibt mir at-most-once Billing-Semantik ohne Outbox-Patterns. Hat 4× verhindert, dass ein Bug in einem AI-Generator zur uneingeschränkten OpenAI-Rechnung wurde.
D/03

FSRS statt SM-2 oder eigenes „smarter" Schema.

chosen
FSRS-6-Implementierung mit den 21 publizierten Default-Weights, State pro Karte (State · Stability · Difficulty · LastReview · NextReview) auf den Flashcard.fsrs*-Feldern
instead of
SM-2 nachbauen oder ein Eigen-Schema „mit Vibes“
reason
SM-2 ist 1990er-Stand und unterschätzt schwere Karten systematisch. Ein eigener Algorithmus ist der schnellste Weg, ein Lern-Tool unbeweisbar zu machen — niemand kann später sagen, ob du Anki schlägst, weil du keinen peer-reviewed-Vergleichspunkt hast. FSRS-6 ist memory-stability-modelliert, hat eine wachsende Forschungs-Community, Anki selbst migriert dorthin. Die Default-Weights laufen ab dem ersten Review — Re-Training pro User ist eine Aufrüst-Option, die ich später dranflanschen kann, ohne den Persistenz-Layer zu ändern. Rückwirkend keine schlechtere Entscheidung getroffen.
D/04

Lokales Redis vor Upstash, mit Memory-Fallback.

chosen
ioredis gegen lokalen redis:7-Container · @upstash/redis als Failover · Map als letzter Fallback · custom Sliding-Window-Rate-Limit
instead of
@upstash/ratelimit + Upstash REST als einzige Wahrheit
reason
Upstash REST ist pro-Call gepricet und latenzanfällig — bei 100 req/min/User wird das schnell teuer, und der Rate-Limit selbst sitzt zwischen Browser und Origin auf dem Hot-Path. Lokaler Redis-Container auf derselben Box: ~0,3ms Roundtrip, frei, deterministisch. Upstash REST bleibt als gleiche Schnittstelle, falls Redis stirbt — und ein In-Memory-Map-Fallback hält Tests und Dev-Maschinen am Leben, die kein Redis brauchen. Der Rate-Limit-Code ist custom (sliding window über sortierte Sets) statt @upstash/ratelimit, weil ich den Algorithmus debugbar haben wollte und drei Backends statt eines bedienen muss.
D/05

Coolify auf einer Box statt Vercel + Managed-Stack.

chosen
Coolify auf einem dedizierten Hetzner-Server orchestriert App + Postgres + Redis + Soketi als Docker-Services. Push to main → Webhook → Coolify rebuildet, Traefik switcht den Upstream. DB-Migrations laufen manuell via docker exec.
instead of
Vercel + Managed-Postgres (Neon) + Managed-Redis (Upstash) — oder k8s
reason
Vercel+Neon+Upstash ist die Default-„Go-fast"-Stack — und summiert sich für ein SaaS mit AI-Calls, ständigem Postgres-Traffic und Soketi-WebSockets schnell auf dreistellig pro Monat, lange bevor User dafür zahlen. Ein Hetzner-Dedicated trägt den ganzen Stack inklusive Datenbank für einen Bruchteil dessen, und Coolify gibt mir die Vercel-DX (push-to-deploy, Build-Logs, ENV-UI) ohne Lock-in. Migration auf einen anderen Anbieter ist compose-up‑woanders. Was an Magie fehlt, fehlt absichtlich: ein Solo-Dev, der k8s betreibt, ist ein Solo-Dev, der nichts mehr versendet.
§ 05Highlights · interesting bits

Things that were not obvious.

Edge-Cases und Details, die erst beim Bauen klar wurden.

CLOZE-Fallback im Front-End-Validator

H/01
Cloze-Karten werden mit Markern {{c1::hidden::hint}} in der Front-Seite gespeichert. Ein AI-Generator hat irgendwann angefangen, den Marker still wegzulassen — der CardType blieb CLOZE, aber die Front-Seite war pure Prosa. Der Lerner sah nichts zu fixen.

Lösung: Beim Speichern downgrade auf STANDARD, wenn keine Marker erkannt werden. Kein Throw, kein Fail-Loud — nur eine stille Korrektur, die die Karte trotzdem nutzbar macht. AI-Outputs sind nie schema-treu; das Backend muss aufräumen, was der Generator falsch labelt. (Commit e28167c.)

NotificationPreference als Gate, nicht als Flag

H/02
Erste Version: User togglt Notifications, App speichert { pushEnabled: false } auf User. Realität: User wollen Achievement-Notifications per Push, aber Group-Messages nur In-App, und Streak-Warnings per Mail um 18 Uhr.

Refactor: NotificationPreference mit(userId, type, channel) als Composite-Key. createNotification() ruft jeweils isNotificationAllowed() pro Kanal — und returnt null, wenn der User opted-out hat. Keine Exception, keine Sentry-Noise: ein opted-out-User ist kein Fehler, nur eine no-op. 29 Notification-Types × 3 Channels = 87 mögliche User-Präferenzen, aber alles default-on und opt-out-able. Reduziert Support-Tickets nachvollziehbar.

Stripe-Webhook idempotent über Event-ID

H/03
Stripe sendet jeden Webhook bis zur 2xx-Antwort. Bei Timeout oder 500 kommt derselbe checkout.session.completed nochmal — und das erste Mal hat den Plan upgedatet, das zweite Mal würde es einen doppelten CreditAdd auslösen.

Lösung: CreditTransaction.stripeEventId als @unique Index. Der Webhook-Handler liest den Event zuerst, erstellt einen signed-and-deduped CreditTransaction-Insert mit dem Event-ID — bei Duplicate-Key-Error → already processed, return 200 ohne Side-Effect. Die ganze Subscription-Logik läuft im selben prisma.$transaction. Wenn die Transaktion rollback't, rollback't auch das Insert — Stripe sendet erneut, dann hat der Insert wieder eine echte Chance. Eine einzige Unique-Constraint, der Rest ist Datenbank-Mechanik.

CSP-Nonce aus proxy.ts

H/04
Next.js 16 hat keine middleware.ts mehr — proxy.ts ist der einzige Hook für per-Request-Logik. Die CSP nutzt nonce-basiertes script-src (kein unsafe-inline mehr), und der Nonce wird pro Request generiert.

proxy.ts setzt x-nonce-Header, layout.tsx liest ihn via headers() und propagiert ihn an alle inline-Script-Tags. Erlaubt strikte CSP ohne jeden Inline-Script-Hash zu pflegen — aber jeder neue <Script> muss nonce mitnehmen, sonst CSP-Block. Ein Lighthouse-Drop nach einem fehlenden Nonce-Prop hat das mal live in 30 Sekunden offenbart.

Audit-Log als Feature, nicht als Compliance

H/05
DSGVO erzwingt eine User-Daten-Löschung mit Beleg. Ich habe das Audit-Log nicht für DSGVO gebaut, sondern um Bug-Reports debuggen zu können — jede sicherheitsrelevante Aktion (Login, Plan-Wechsel, Card-Delete, Admin-Override) landet als AuditLog-Row mit Actor + Target + Diff.

Nebeneffekt: jeder Support-Fall („meine Karten sind weg") wird in Sekunden nachvollziehbar — Cron-Job? User selbst? Admin? Diff zeigt was vorher dran stand. Im Admin-Dashboard ist das ein Filter über Actor-ID, nicht ein Grep über Logs. Compliance fällt als Side-Effect ab, nicht andersrum.

Manual-SQL-Migrations statt prisma migrate dev

H/06
Prisma's Shadow-Database scheitert reproduzierbar an der 0_init-Migration — irgendein Whitespace im generierten SQL kippt das Diff. Statt zu fixen: Migrationen werden manuell als migration.sql in prisma/migrations/ abgelegt und mit prisma migrate deploy gefahren.

Jede Enum-Erweiterung nutzt IF NOT EXISTS, damit Re-Runs idempotent sind. Plus: in Prod muss npx prisma@6 gepinned werden, weil Prisma 7 inkompatible Config-Breaking-Changes hat — und npx ohne Pin pulled die Latest. Eine 15-Zeilen-Note im CLAUDE.md, weil mich der Stack zweimal beim Migrieren erwischt hat.
§ 06Stack · in production

What's running.

Working toolchain — nichts Theoretisches.
Next.js 16 · App RouterTypeScript 5Prisma 6 · Postgres + pgvectorNextAuth.js v5 · JWTTailwind 4 · shadcn/uiRedis · ioredis · sliding-windowStripe · Subs + Credit-PacksOpenAI · gpt-5-nano · gpt-5-miniFSRS · per-user paramsSoketi · WebSocket fallbackSentry · proxy + audit-logPWA · IndexedDB · BG syncTipTap · rich notesCoolify · dedicated HetznerCloudflare · Traefik · push-to-deployVitest · Playwright · k6
§ 07Reflection · takeaways

What I learned.

Live als SaaS auf flashbuddy.app. Diese Dinge nehme ich in die nächsten mit.

Ein zentraler API-Handler ist die Investition.

withApiHandler ist die einzige Architekturentscheidung, die ich rückwirkend früher getroffen hätte. CSRF, Rate-Limit, Audit-Log, Sentry, Body-Size, Ban-Check, Request-ID — alles Dinge, die per-route nachzurüsten ein Wochenende kostet, aber zentral eine Stunde. Ich habe vier Sicherheits-Cross-Cuts nach dem ersten Live-Tag eingezogen, ohne 200 Routes anzufassen. Bei Solo-Bau ist die Frage „macht das Boilerplate weh genug, dass ich ne Helper-Funktion baue" der falsche Reflex — die Frage ist „kann ich es mir leisten, einmal zu vergessen". Bei Auth/CSRF/Audit ist die Antwort immer nein.

Gamification ist ein Retention-Multiplikator.

Ich war anfangs allergisch gegen Streaks und XP — wirkt billig, manipulativ, „dunkles Pattern". Nach 6 Monaten Daten: User mit aktivem Streak reviewen 3,8× häufiger als User ohne. Nicht weil sie XP wollen, sondern weil das Streak-Badge eine extern sichtbare Selbstverpflichtung ist — dasselbe Prinzip, das Duolingo trägt. Streak-Freezes als „Forgiveness-Mechanik" verhindern, dass eine vergessene U-Bahn-Fahrt die ganze Habit-Schleife killt. Lernen ist Gewohnheits-Engineering, nicht Algorithmus-Engineering. FSRS ohne Habit-Layer ist brillante Software, die niemand öffnet.

◦ NEXT CASE · 10 / 11
Capypad
← alle Projekte