CASE · 092025 — 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.

§ 01Problem · 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.

§ 02Constraints · operating box

The box it had to fit in.

Solo builder, real user traffic, 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.
§ 03Architecture · 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·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.

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() → AI call → deductCredits() inside the same Prisma transaction with an idempotency key
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. Pre-check + transactional deduct with an idempotency key catches the race where two requests both “check, deduct,” and both get the call through. The transaction gives me at-most-once billing semantics without an outbox pattern. Has prevented an unbounded OpenAI bill from a buggy generator at least four times.
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.
§ 05Highlights · 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. (Commit e28167c.)

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. Cuts support tickets in a way you can read off the chart.

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 dropped middleware.ts proxy.ts is the only 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.
§ 06Stack · 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 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 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 is a retention multiplier.

I was allergic to streaks and XP at first — felt cheap, manipulative, “dark pattern.” After 6 months of data: users with an active streak review 3.8× more often than users without. Not because they 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 · 10 / 11
Capypad
← all projects