Skip to content

Repository files navigation

Crypto Trading Academy — LMS

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.


Quick start

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:3000

Demo accounts

All use the password Password123.

Email 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:

  1. Sign in as admin@academy.test → /admin/payments. One slip is pending, with a real (generated) slip image. Approve it.
  2. Sign in as review@academy.test. The account is now active — courses, sessions and the journal have all unlocked.
  3. Sign in as active1@academy.test → /journal/analytics. 27 closed trades, equity curve, drawdown, per-setup breakdown, P&L calendar.
  4. Still as active1 → /sessions. One session starts ~20 minutes from seed time, so the Join button is live and reveals the meeting link.
  5. Sign in as pending@academy.test and try /journal or /sessions. Both redirect to /payment. /courses shows the one free-preview lesson.
  6. Sign out entirely and visit / — the marketing page renders public announcements and the free-preview lesson through RLS alone.

Verification

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 + build

The 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.

What each test layer is for

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.


Project layout

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

Reading order, if you are new to it

  1. supabase/migrations/…_rls.sql — the authorization model. Everything else follows from it.
  2. supabase/migrations/…_rpcs.sql — approve_payment() is the core business transaction.
  3. src/lib/journal/metrics.ts — the domain maths, with the P&L convention documented at the top.
  4. DECISIONS.md — why things are the way they are.

The payment flow

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.


The certificate flow

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.


Common tasks

# 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.


Going to production

Supabase

  1. Create a project, then npx supabase link --project-ref <ref> and npm run db:push.
  2. Turn on email confirmation. supabase/config.toml sets auth.email.enable_confirmations = false for local dev only, so seeding and Playwright can sign in immediately. In the dashboard: Authentication → Email → enable "Confirm email".
  3. Add your production URL to Authentication → URL Configuration, including /auth/callback and /auth/confirm.
  4. Configure SMTP (or Resend) so auth emails actually send.
  5. Check that the storage buckets came across and that only avatars and announcement-images are public.
  6. Create the first superadmin by hand — the seed script is for local demo data:
    update public.profiles
       set role = 'superadmin', account_status = 'active'
     where email = 'you@yourdomain.com';
    Run this as the postgres role in the SQL editor; set_user_role() deliberately refuses to create the first superadmin from nothing.

Vercel

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

Scheduled functions

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.

Before you launch

  • Replace the bank details in /admin/settings with the real account
  • Set the real support email, WhatsApp and Telegram links
  • Have a Sri Lankan lawyer review /privacy and /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

Security notes

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.


Troubleshooting

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.


Licence

Proprietary. All rights reserved.

Releases

Packages

Contributors

Languages