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

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.

Flashbuddy — Screenshot
live · flashbuddy.app
§ 01 — Problem · motivation

Why this exists.

Anki is powerful but raw. Quizlet is pretty but dumb. Both ignore that learning is a habit problem, not a UI problem.

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.

§ 02 — Constraints · operating box

The box it had to fit in.

Solo builder, publicly reachable, GDPR-grade. No Vercel lock-in, no VC burn rate, no “only when it scales” thinking.
C/01 · OPS
Solo dev. Every architectural decision has to be maintainable without a team — no microservices, no event-bus choreography, no distributed-tracing stage set.
C/02 · HOSTING
Coolify on a dedicated Hetzner server — app, Postgres, Redis, and Soketi all run as Docker services under one orchestrator. Auto-deploy on push-to-main via webhook, Cloudflare in front (TLS, Zero Trust). No cloud-lock cost; the whole stack costs less than a single AWS RDS instance.
C/03 · GDPR
Made in Germany, EU hosting, complete GDPR data export across all 28+ Prisma relations, account deletion with hard cascade. PostHog on the EU endpoint.
C/04 · CREDITS
AI isn’t free. Four tiers (FREE/STARTER/PREMIUM/PRO) with monthly credit reset and top-up packs. Each action costs 1–5 credits, billed cleanly — before the call goes out.
C/05 · OFFLINE
PWA with IndexedDB + background sync. Commuters learn on the U-Bahn — anyone who loses reviews because Wi-Fi flickers does not come back.
C/06 · SECURITY
User-generated content, AI-generated content, Stripe webhooks, API keys. CSRF (header verify, not tokens), nonce-based CSP, audit log with DB trail, Sentry on anything that throws a throw.
C/07 · REAL-TIME
29 notification types — AI ready, friend request, challenge won, achievement unlocked. WebSocket via Soketi, polling fallback, NotificationPreference per user per channel.
C/08 · I18N FLOOR
Fully German — no hardcoded English string heap, every error message as an ERR constant in error-messages.ts. One English translation layer that ships even if no string lives in code.
§ 03 — Architecture · request to review

How it runs.

Three lanes: top is the synchronous request path (browser → withApiHandler pipeline → Postgres), middle is the AI lane (credit gate → OpenAI → generator → DB), bottom is the side-effect lane (Soketi WebSocket, Stripe webhook, Sentry). Redis for rate limiting + cache, all on a single Hetzner box, Cloudflare Edge in front.
flashbuddy.app·host coolify·deploy push-to-main
tiers FREE / STARTER / PREMIUM / PRO
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 · row lock
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 retention
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

◦ Schematic view, numbers are illustrative.

§ 04 — Decisions · trade-offs

Five deliberate choices.

Per decision: what was chosen, instead of what, and why.
D/01

One central withApiHandler instead of per-route boilerplate.

chosen
Generic 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.
instead of
30 lines of boilerplate per route for the same security chain
reason
With 200+ API routes, copy-paste security is a question of when, not if. A forgotten CSRF check or a missing rate limit on a single route is exactly the class of bug that an audit log can’t fix in retrospect. A central handler enforces the defaults: auth is opt-out, not opt-in. Tests mock a single surface, error handling stays consistent, and new cross-cutting concerns (audit logging came later) land in one place. The abstraction pays for itself by route 4 — past that it is pure gravity.
D/02

Credits before the AI call, not after.

chosen
checkCredits() → deductCredits() in its own Prisma transaction with a row lock → AI call → refundUsage() on failure
instead of
Optimistic post-hoc deduction, or soft limits derived from logs
reason
OpenAI costs real money per request. A FREE user (20 credits/month) on an empty wallet must not be able to fire another gpt-5-mini vision call — not even by clicking two tabs in parallel. The row lock (FOR 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.
D/03

FSRS instead of SM-2 or a homegrown “smarter” schedule.

chosen
FSRS-6 implementation with the 21 published default weights, per-card state (state · stability · difficulty · last review · next review) on the Flashcard.fsrs* fields
instead of
Reimplementing SM-2, or rolling a custom algorithm “by vibes”
reason
SM-2 is 1990s-era and systematically underrates hard cards. A homegrown algorithm is the fastest way to make a learning tool unprovable — nobody can later tell whether you beat Anki, because you have no peer-reviewed reference point. FSRS-6 is memory-stability modelled, has a growing research community, and Anki itself is migrating to it. The default weights work from the first review — per-user retraining is an upgrade I can bolt on later without changing the persistence layer. Not a worse decision in hindsight.
D/04

Local Redis before Upstash, with a memory fallback.

chosen
ioredis against a local redis:7 container · @upstash/redis as failover · a Map as last-resort fallback · a custom sliding-window rate limiter
instead of
@upstash/ratelimit + Upstash REST as the only source of truth
reason
Upstash REST is priced per call and latency-sensitive — at 100 req/min/user that gets expensive fast, and the rate limiter itself sits between browser and origin on the hot path. A local Redis container on the same box: ~0.3ms round trip, free, deterministic. Upstash REST stays available as the same interface if Redis dies — and an in-memory Map fallback keeps tests and dev machines alive without needing Redis at all. Rate-limit code is custom (sliding window over sorted sets) instead of @upstash/ratelimit because I wanted the algorithm debuggable and had to serve three backends, not one.
D/05

Coolify on a single box instead of Vercel + managed stack.

chosen
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.
instead of
Vercel + managed Postgres (Neon) + managed Redis (Upstash) — or k8s
reason
Vercel+Neon+Upstash is the default “go fast” stack — and for a SaaS with AI calls, constant Postgres traffic, and Soketi WebSockets it stacks up to three figures a month long before users pay for it. A dedicated Hetzner box runs the whole stack including the database for a fraction of that, and Coolify gives me the Vercel-style DX (push-to-deploy, build logs, env UI) without the lock-in. Migration to another provider is compose-up‑elsewhere. What this lacks in magic, it lacks deliberately: a solo dev running k8s is a solo dev who isn’t shipping.
§ 05 — Highlights · interesting bits

Things that were not obvious.

Edge cases and details that only became clear during build.

CLOZE fallback in the front-end validator

H/01
Cloze cards store front-side markers like {{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

H/02
First version: a user toggled notifications, the app stored { 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

H/03
Stripe retries every webhook until it gets a 2xx. On a timeout or 500, the same 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

H/04
Next.js 16 deprecates 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

H/05
GDPR demands a deletion paper trail. I didn’t build the audit log for GDPR — I built it to be able to debug bug reports. Every security-relevant action (login, plan change, card delete, admin override) lands as an 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

H/06
Prisma’s shadow database fails reproducibly on the 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.
§ 06 — Stack · in production

What's running.

Working toolchain — nothing theoretical.
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 retentionSoketi · polling fallbackSentry · proxy + audit logPWA · IndexedDB · BG syncTipTap · rich notesCoolify · dedicated HetznerCloudflare · Traefik · push-to-deployVitest · Playwright · k6
§ 07 — Reflection · takeaways

What I learned.

Live as a SaaS at flashbuddy.app. These are the things I’m taking forward.

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.

◦ NEXT CASE · 02 / 10
Capypad → ↗
← all projects