A learning management system for a crypto trading academy, built for the Sri Lankan market: manual bank-transfer payments with admin verification, a cohort-based curriculum with drip release, live Zoom sessions, a professional trading journal, and a live market dashboard.
Next.js 15 (App Router) · TypeScript strict · Supabase (Postgres, Auth, Storage, Realtime) · Tailwind + shadcn/ui
Authorization lives in Postgres row-level security, not in the application. The middleware and route guards exist for a decent user experience; if you deleted them, the app would get ugly but would not leak a single row. There is a pgTAP suite that proves this.
Requires Node 20.11+, Docker (for the local Supabase stack) and npm.
git clone <repo> && cd crypto-trading-lms
npm install
# 1. Start Postgres, Auth, Storage, Realtime locally
npm run db:start # first run pulls ~2GB of images
# 2. Point the app at the local stack
cp .env.example .env.local
npx supabase status # copy the anon key and service_role key into .env.local
# 3. Apply migrations and load demo data
npm run db:reset
npm run seed
# 4. Run it
npm run dev # http://localhost:3000All use the password Password123.
| What it demonstrates | |
|---|---|
superadmin@academy.test |
Everything: roles, audit log, app settings |
admin@academy.test |
Day-to-day operator — the payment queue has one slip waiting |
active1@academy.test |
Paid student with 30 trades, lesson progress, coach feedback |
active2@academy.test |
Paid student on the other batch, lighter journal |
pending@academy.test |
Signed up, never paid — every gated route redirects to /payment |
review@academy.test |
Slip submitted, sitting in the queue |
rejected@academy.test |
Slip rejected (underpaid) — can resubmit |
A five-minute tour that exercises the core flow:
- Sign in as
admin@academy.test→/admin/payments. One slip is pending, with a real (generated) slip image. Approve it. - Sign in as
review@academy.test. The account is now active — courses, sessions and the journal have all unlocked. - Sign in as
active1@academy.test→/journal/analytics. 27 closed trades, equity curve, drawdown, per-setup breakdown, P&L calendar. - Still as
active1→/sessions. One session starts ~20 minutes from seed time, so the Join button is live and reveals the meeting link. - Sign in as
pending@academy.testand try/journalor/sessions. Both redirect to/payment./coursesshows the one free-preview lesson. - Sign out entirely and visit
/— the marketing page renders public announcements and the free-preview lesson through RLS alone.
npm run verify # typecheck + lint + unit tests
npm run db:test # pgTAP: 108 assertions on RLS, trade metrics, completion
npm run test:e2e # Playwright: payment approval, zoom gating, certificates
npm run build # production build, reports the bundle budget
npm run verify:all # verify + db:test + buildThe e2e suite seeds the database, so it needs the local stack running
(npm run db:start). It refuses to run against a non-local Supabase URL.
Current state, all verified against the local stack:
| Check | Result |
|---|---|
tsc --noEmit |
clean |
eslint |
clean |
| Vitest | 96 passing (journal maths, magic-byte sniffing, rich-text sanitising) |
| pgTAP | 108 passing (44 RLS isolation, 35 trade metrics, 29 course completion) |
| Playwright | 34 passing (25 desktop, 9 mobile) |
next build |
clean; every student route under the 250KB first-load budget |
Largest student route is /journal at 241KB. Two admin routes exceed 250KB
(TipTap + dnd-kit) — see DECISIONS.md #27.
pgTAP is the security suite and the only layer that can prove a policy — it
impersonates authenticated with a planted JWT claim and asks Postgres directly.
It covers the requirements by name:
- a pending student cannot read
live_sessions.meeting_url - a student cannot read another student's trades
- a student cannot read another student's payment slip
- a student cannot approve their own payment or escalate their own role
- unpublished and drip-scheduled content stays hidden
- quiz answer keys never reach a student
- one student cannot read another's course completion or certificate
Run it before merging anything that touches a policy, a view or an RPC.
Vitest covers the journal formulas — the leverage convention, R-multiples, drawdown, and the float-rounding edge cases.
Playwright covers the flows that cross every layer end to end. The certificate spec exists because of a specific class of bug it catches: every piece of that feature was built and typechecked, but nothing in the UI called the claim action, so no student ever received a certificate. Only exercising the page finds that.
src/
app/
(marketing)/ public: landing, pricing, privacy, terms
(auth)/ login, signup, onboarding, password reset
(app)/ signed-in student surface
payment/ bank details + slip upload + history
dashboard/ continue learning, next session, journal snapshot
courses/ course → lesson player with progress, Q&A, quizzes
sessions/ live sessions, join window, recordings, .ics
journal/ trade log, analytics, daily notes, CSV import/export
markets/ watchlist, global stats, movers, candlestick charts
profile/ settings/
(admin)/admin/ staff console — payments, students, batches, courses,
sessions, announcements, quizzes, analytics,
plus superadmin-only team / audit / settings
announcements/ one URL for both anon and signed-in readers
verify/[serial]/ public certificate verification
account-hold/ landing for BLOCK_LOGIN_UNTIL_PAID (off by default)
offline/ service-worker fallback
api/ cached market proxies, .ics, certificate PDF
components/
ui/ shadcn primitives
charts/ chart palette + equity curve, breakdowns, calendar
market/ WebSocket manager, tickers, candlesticks
journal/ tag input, symbol picker, screenshot uploader
lesson/ video embed, player, resources, discussion
editor/ TipTap rich-text editor
nav/ app shell, admin shell, notifications
lib/
supabase/ browser / server / service / middleware clients
auth/ session helpers, route classification
journal/metrics.ts canonical metric maths (+ its tests)
market/ types, server-side cached fetchers
storage/ magic-byte sniffing, signed URLs
schemas/ Zod schemas shared by forms and actions
richtext.tsx TipTap JSON → sanitised HTML
i18n/README.md Sinhala scaffold: what is ready, what it would take
supabase/
migrations/ 17 migrations, ordered and commented
tests/ pgTAP suites
functions/ scheduled Edge Functions (reminders, alerts, expiry)
scripts/
seed.ts demo data via the Auth Admin API
generate-icons.ts PWA icon set (npm run icons)
e2e/ Playwright specs + per-role auth setup
public/ manifest, service worker, generated icons
supabase/migrations/…_rls.sql— the authorization model. Everything else follows from it.supabase/migrations/…_rpcs.sql—approve_payment()is the core business transaction.src/lib/journal/metrics.ts— the domain maths, with the P&L convention documented at the top.DECISIONS.md— why things are the way they are.
The heart of the product, so worth stating in full.
student signs up
→ /onboarding (name, phone, batch)
→ /payment: bank details from app_settings, amount from the batch
→ uploads slip
· client compresses images (phone photos are 4-8MB)
· Server Action sniffs the MAGIC BYTES, not the declared MIME type
· stored at payment-slips/<uid>/<uuid>.<ext> in a private bucket
· submit_payment_slip() inserts the row, flips the profile to
under_review, and notifies every admin
→ admin opens /admin/payments
· queue flags duplicate reference numbers and underpayments
· slip preview via a 5-minute signed URL, minted on open (not on list)
· Approve → approve_payment() in ONE transaction:
payment → approved
enrollment → active (seat limit checked with the batch row locked)
profile → active
referral credited
notification queued
audit rows written by trigger
· email sent AFTER the transaction commits
→ student reloads: full access
Rejection requires a reason of at least five characters (enforced by a CHECK
constraint, not just the form), sends it to the student, and returns them to
/payment so they can resubmit. Nothing is lost.
How a student gets a certificate once they finish a course.
student completes the course
· every PUBLISHED lesson marked complete (90% watch auto-completes)
· every PUBLISHED module quiz passed
→ /courses/<slug> shows a card: "You finished this course"
· state comes from get_course_completion(), never from the page's own
progress bar — see DECISIONS.md #25 for why that distinction matters
→ student clicks "Claim your certificate"
· issue_certificate() re-checks completion server-side, freezes
recipient_name, mints serial CTA-<year>-<random>, queues a
notification. Idempotent: a second claim returns the same serial.
→ card flips to "Download PDF" + "Verify"
· GET /api/certificates/<id> renders A4 landscape with pdf-lib
· entitlement is the caller's own RLS-bound SELECT, so another
student gets a 404 and a signed-out request gets the login redirect
· Cache-Control: private, no-store
→ also listed permanently on /profile
→ anyone, signed out, can check /verify/<serial>
The card is honest about the states that are not eligible, because each has a different way forward:
| State | What the card says |
|---|---|
| All visible lessons done, drip content pending | "N more lessons unlock on <date>" |
| Quiz attempts exhausted, still unpassed | "Ask your coach to reset your attempts" |
| Quizzes still outstanding | "Pass N more quizzes to earn your certificate" |
| Eligible | "Claim your certificate" |
| Already issued | Serial, "Download PDF", "Verify" |
Issuance is a click rather than automatic on the last lesson: the certificate freezes the student's name and publishes a permanent serial, so they get the chance to correct their profile first.
# Database
npm run db:start / db:stop
npm run db:reset # re-apply all migrations from scratch
npm run db:diff -- add_something # generate a migration from local changes
npm run db:types # regenerate src/lib/supabase/database.types.ts
npm run db:test # pgTAP
# App
npm run dev / build / start
npm run typecheck / lint / lint:fix / format
npm run test / test:watch
npm run test:e2e / test:e2e:ui
npm run seed # re-seed (idempotent; refuses non-local targets)After changing the schema, always run npm run db:types. The generated
types drive every query in the app; skipping it means the compiler is checking
against yesterday's schema.
- Create a project, then
npx supabase link --project-ref <ref>andnpm run db:push. - Turn on email confirmation.
supabase/config.tomlsetsauth.email.enable_confirmations = falsefor local dev only, so seeding and Playwright can sign in immediately. In the dashboard: Authentication → Email → enable "Confirm email". - Add your production URL to Authentication → URL Configuration, including
/auth/callbackand/auth/confirm. - Configure SMTP (or Resend) so auth emails actually send.
- Check that the storage buckets came across and that only
avatarsandannouncement-imagesare public. - Create the first superadmin by hand — the seed script is for local demo data:
Run this as the
update public.profiles set role = 'superadmin', account_status = 'active' where email = 'you@yourdomain.com';
postgresrole in the SQL editor;set_user_role()deliberately refuses to create the first superadmin from nothing.
Set every variable from .env.example. Specifically:
| Variable | Why it matters in production |
|---|---|
SUPABASE_SERVICE_ROLE_KEY |
signed URLs, scheduled jobs. Server-side only — never prefix it with NEXT_PUBLIC_ |
ENABLE_EMAIL=true + RESEND_API_KEY |
without these, approval emails are logged to the console, not sent |
UPSTASH_REDIS_REST_URL / _TOKEN |
strongly recommended. Without them rate limiting falls back to Postgres, which works; the in-memory tier is per-instance and effectively no limit |
CRON_SECRET |
required by the scheduled Edge Functions, or anyone can trigger them |
NEXT_PUBLIC_SITE_URL |
used in email links and OAuth redirects |
BLOCK_LOGIN_UNTIL_PAID |
leave false — see DECISIONS.md #3 |
npx supabase functions deploy session-reminders
npx supabase functions deploy price-alerts
npx supabase functions deploy expire-enrollments
npx supabase secrets set CRON_SECRET=<same value as Vercel>Then add the cron schedules (Supabase dashboard → Edge Functions → Schedules, or
pg_cron). Suggested: reminders hourly, price alerts every 5 minutes, expiry
daily.
- Replace the bank details in
/admin/settingswith the real account - Set the real support email, WhatsApp and Telegram links
- Have a Sri Lankan lawyer review
/privacyand/terms. They are good-faith templates covering the PDPA's disclosure requirements, not legal advice - Create your batches, then the curriculum
- Send yourself a test payment through the whole flow
- Confirm the approval email arrives (not just the in-app notification)
- Promote a second superadmin, so one lost laptop is not an outage
Worth knowing before you change anything.
RLS is the boundary. Adding a table means adding policies and a grant in
…_grants.sql. A table with no grant is unreachable through the API, which is
the correct default — that is why the file is explicit rather than relying on
auto-exposure.
Meeting links are the sharpest edge. Students have no SELECT on
live_sessions at all. They read live_sessions_student (which omits the
credentials) and call get_session_access(), which checks entitlement and the
time window. live_sessions is deliberately excluded from the Realtime
publication, because a postgres_changes payload would broadcast meeting_url
to the whole batch. Do not "just add it" to fix a refresh bug.
Quiz answers are the same shape of problem. correct_index never leaves the
database before submission; grading happens in submit_quiz_attempt().
Journal metrics are computed by a trigger. A client-supplied pnl is
overwritten, and there is a test asserting it. Never trust a metric that arrives
from a form.
Uploads are sniffed, not trusted. validateUpload() reads the magic bytes.
The declared MIME type is a UI hint only, and the stored type is the sniffed one.
The service-role client bypasses RLS. It lives in
src/lib/supabase/service.ts, is marked server-only, and an ESLint rule
restricts where it can be imported. Its legitimate uses are narrow: minting
signed URLs after an entitlement check, reading email addresses for transactional
mail, the seed script, and scheduled jobs. Reaching for it to "get past RLS" in a
request path means the RPC is missing.
permission denied for table … — a missing grant, not a missing policy. Add
it to …_grants.sql. Remember service_role needs grants too; bypassing RLS is
not bypassing GRANT.
A query returns never in TypeScript — the generated types are stale. Run
npm run db:types. If it persists, check whether a helper is typed
{ data: T | null } instead of PostgrestSingleResponse<T>: with the loose
shape, TypeScript binds T to the union's null branch and every property access
resolves to never.
Prices show "Delayed" — the WebSocket could not connect, usually a network blocking port 9443. The app is polling the cached REST proxy every 10s, which is by design (DECISIONS.md #14).
Failed to set Next.js data cache … over 2MB — an upstream response is too
big for the data cache, so it is not being cached at all. Use the
uncached-fetch-plus-unstable_cache pattern in lib/market/server.ts.
pgTAP passes locally but fails in CI, or vice versa — an assertion is probably using a global count instead of a fixture-scoped one, so its result depends on whether the seed has run. Scope it.
supabase db reset fails with exit 125 — a transient Docker container
error. Run it again.
Proprietary. All rights reserved.