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.
Why this exists.
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.
The box it had to fit in.
throw sagt.How it runs.
Five deliberate choices.
Ein zentraler withApiHandler statt Per-Route-Boilerplate.
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.Credits vor dem AI-Call, nicht danach.
checkCredits() → AI-Call → deductCredits() innerhalb derselben Prisma-Transaktion mit Idempotenz-KeyFSRS statt SM-2 oder eigenes „smarter" Schema.
Flashcard.fsrs*-FeldernLokales Redis vor Upstash, mit Memory-Fallback.
redis:7-Container · @upstash/redis als Failover · Map als letzter Fallback · custom Sliding-Window-Rate-LimitCoolify auf einer Box statt Vercel + Managed-Stack.
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.Things that were not obvious.
CLOZE-Fallback im Front-End-Validator
{{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
{ 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
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
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
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
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.What's running.
What I learned.
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.