Every card adaptive. Every streak earned.
Full learning platform: FSRS spaced repetition, AI-generated flashcards and quizzes from PDFs / notes, gamification with XP / streaks / quests / cosmetics, four Stripe tiers, and a classroom mode for teachers. Next.js 16 App Router · Prisma 6 · Postgres · Redis · Stripe · OpenAI · Soketi. Self-hosted via Coolify on a dedicated Hetzner server behind Cloudflare Edge.

Why this exists.
The established spaced-repetition market has two poles. Anki is algorithmically honest — SM-2 / FSRS, sharp defaults, decades of iteration — but the UI feels like research software from 2008, and gamification is, at best, not present. Quizlet has the look, but the learning algorithm is a glorified ordering — no FSRS, no memory-stability modelling, no claim to catch you at the moment of forgetting.
Flashbuddy closes the gap: an FSRS learning core that runs past Anki on the science side, embedded in a Duolingo-style gamification layer (XP, streaks, streak freezes, daily quests, unlockable cosmetics), wrapped in a modern German-language UI with AI flashcard and quiz generation from PDFs, notes, or free text. Plus a B2B classroom track for teachers with assignments — because the real money is in school, not in hobby learners.
The box it had to fit in.
throw.How it runs.
◦ Schematic view, numbers are illustrative.
Five deliberate choices.
One central withApiHandler instead of per-route boilerplate.
withApiHandler<T>(handler, opts, schema) handles auth, Zod validation, CSRF verify, rate limiting, body-size cap, ban check, audit log, Sentry capture, and request-ID propagation in one function.Credits before the AI call, not after.
checkCredits() → deductCredits() in its own Prisma transaction with a row lock → AI call → refundUsage() on failureFOR UPDATE) serialises concurrent deductions, credits are taken before the call goes out, and if the call fails, refundUsage() returns them to the buckets they came from. No AI call runs without credits already deducted.FSRS instead of SM-2 or a homegrown “smarter” schedule.
Flashcard.fsrs* fieldsLocal Redis before Upstash, with a memory fallback.
redis:7 container · @upstash/redis as failover · a Map as last-resort fallback · a custom sliding-window rate limiterCoolify on a single box instead of Vercel + managed stack.
Coolify on a dedicated Hetzner server orchestrates app + Postgres + Redis + Soketi as Docker services. Push to main → webhook → Coolify rebuilds, Traefik flips the upstream. DB migrations run manually via docker exec.Things that were not obvious.
CLOZE fallback in the front-end validator
{{c1::hidden::hint}}. At one point an AI generator started silently dropping the markers — the CardType stayed CLOZE, but the front side was plain prose. The learner saw nothing to fill in.Fix: on save, downgrade to STANDARD when no markers are detected. No throw, no fail-loud — a silent correction that still leaves the card usable. AI outputs are never schema-faithful; the backend has to clean up what the generator mis-labels.
NotificationPreference as a gate, not a flag
{ pushEnabled: false } on User. Reality: users want achievement notifications via push, group messages only in-app, and streak warnings by email at 6 pm.Refactor:
NotificationPreference with (userId, type, channel) as a composite key. createNotification() calls isNotificationAllowed() per channel and returns null when the user has opted out. No exception, no Sentry noise: an opted-out user is not an error, just a no-op. 29 notification types × 3 channels = 87 possible user preferences, all default-on and individually opt-out-able.Stripe webhook idempotent by event id
checkout.session.completed arrives twice — and the first delivery already upgraded the plan; the second would trigger a duplicate credit add.Fix:
CreditTransaction.stripeEventId as a @unique index. The webhook handler reads the event, then writes a signed-and-deduped CreditTransaction carrying the event ID — on duplicate-key error → already processed, return 200, no side effects. The whole subscription flow runs inside the same prisma.$transaction. If the transaction rolls back, the insert rolls back too — Stripe retries and the insert has another real chance. One unique constraint, the rest is database mechanics.CSP nonce from proxy.ts
middleware.ts — proxy.ts is the intended hook for per-request logic. The CSP runs nonce-based script-src (no more unsafe-inline), with a fresh nonce per request.proxy.ts sets the x-nonce header; layout.tsx reads it via headers() and passes it to every inline-script tag. Strict CSP without maintaining inline-script hashes — but every new <Script> has to carry the nonce prop or it gets blocked. A Lighthouse drop after a missing nonce caught it live in 30 seconds once.Audit log as a feature, not as compliance
AuditLog row with actor + target + diff.Side effect: every support case (“my cards are gone”) becomes traceable in seconds — cron job? user themselves? admin? The diff shows what was there before. In the admin dashboard that’s a filter on actor id, not a grep over logs. Compliance falls out as a side effect, not the other way around.
Hand-written SQL migrations instead of prisma migrate dev
0_init migration — some whitespace in the generated SQL flips the diff. Rather than fix it: migrations live as hand-written migration.sql files in prisma/migrations/, applied via prisma migrate deploy.Every enum addition uses
IF NOT EXISTS so reruns stay idempotent. Plus, in production npx prisma@6 must be pinned, because Prisma 7 ships breaking config changes — and npx without a pin will pull the latest. A 15-line note in CLAUDE.md, written after the stack caught me twice on migration day.What's running.
What I learned.
A central API handler is the investment.
withApiHandler is the only architectural decision I would have made earlier in hindsight. CSRF, rate limit, audit log, Sentry, body size, ban check, request id — all things that take a weekend to retrofit per-route, but an hour to centralise. I wired four security cross-cuts in after the first live day without touching 200 routes. Building solo, the question “does this boilerplate hurt enough that I should write a helper” is the wrong reflex — the question is “can I afford to forget it once.” For auth, CSRF, and audit, the answer is always no.
Gamification makes learning a habit.
I was allergic to streaks and XP at first — felt cheap, manipulative, “dark pattern.” Today I think they are the core of the product — not because users want XP, but because the streak badge is an externally visible commitment device — the same mechanism that carries Duolingo. Streak freezes as a “forgiveness mechanic” keep one missed train ride from killing the entire habit loop. Learning is habit engineering, not algorithm engineering. FSRS without a habit layer is brilliant software that nobody opens.