README
Elite Academy
Bangladesh-focused board exam (HSC/SSC) preparation platform — Next.js web, Expo React Native Android, and a unified Node.js backend.
What is this?
Elite Academy lets students practice real past board questions, take timed mock tests, see instant results with explanations, track personal analytics, and compete on subject/board/year-wise leaderboards. Admins manage all content from a web admin panel.
See PHASES.md for the full A→Z roadmap (8 phases, ~44–56 dev days).
Repository layout
This is a pnpm + Turborepo monorepo.
elite-academy/
├── apps/
│ ├── web/ # Next.js 14 (App Router) — public site + admin panel
│ └── mobile/ # Expo SDK 51 — React Native + NativeWind v4
├── services/
│ └── api/ # Fastify + Prisma backend
├── packages/
│ ├── shared-types/ # TypeScript types & enums
│ ├── validators/ # Zod schemas (reused across API + web)
│ ├── ui/ # Shared React components
│ └── config/ # Env loader (t3-env)
├── supabase/
│ └── migrations/ # SQL migrations (mirrors Prisma schema)
├── docs/ # Architecture diagrams, ADRs
└── PHASES.md # Full development plan with tracker
Tech stack
| Layer | Tech |
|---|---|
| Web | Next.js 14 App Router · TypeScript · Tailwind CSS · shadcn/ui |
| Admin | Next.js /admin route group with role-gated middleware |
| Mobile | React Native · Expo SDK 51 · Expo Router · NativeWind v4 |
| API | Node.js · Fastify · TypeScript · Prisma ORM · Zod |
| DB | PostgreSQL (Supabase) |
| Auth | Supabase Auth (email/password, phone OTP, Google, Facebook) |
| Storage | Supabase Storage |
| Realtime | Supabase Realtime |
| Cache/Queue | Upstash Redis · BullMQ |
| Push | Expo Push (FCM) |
| Payments | SSL Commerz · Stripe · Google Play Billing |
| Observability | Sentry · PostHog |
| CI/CD | GitHub Actions · Vercel · Render · EAS |
Prerequisites
- Node.js ≥ 20 — see
.nvmrc - pnpm ≥ 9 —
npm install -g pnpm - Docker (for local Supabase Postgres + Redis) — optional but recommended
- Expo CLI — installed per-project via
pnpm
Getting started
# 1. Install all workspaces
pnpm install
# 2. Copy env file and fill in real values when you have them
cp .env.example .env
# 3. (Optional) Start local Supabase + Redis
# If using Supabase Cloud, skip this and just fill DATABASE_URL.
# 4. Generate Prisma client + push schema (when DATABASE_URL is set)
pnpm --filter @elite/api prisma:generate
pnpm --filter @elite/api prisma:migrate:dev
# 5. Seed the database (boards, years, subjects, demo paper)
pnpm --filter @elite/api seed
# 6. Run everything in dev mode
pnpm dev
| App | Local URL |
|---|---|
| Web | http://localhost:3000 |
| API | http://localhost:4000 |
| Mobile (Expo) | http://localhost:8081 (or Expo Go on device) |
Payments (SSL Commerz)
Phase 5 ships a hand-rolled SSL Commerz subscription integration (no SDK). Two pricing tiers live in packages/shared-types/src/entities.ts:
| Plan | Price | Duration |
|---|---|---|
| Monthly | ৳299 | 30 days |
| Yearly | ৳1,999 | 365 days |
A subscription grants User.isPremium=true while User.premiumUntil > now. Premium papers are gated on both web and mobile via requirePaperEntitlement() in services/api/src/lib/entitlement.ts.
Local sandbox test
- Sign up at https://developer.sslcommerz.com and grab your sandbox
Store ID+Store Password. - Add to
.env:SSLCOMMERZ_STORE_ID=your-sandbox-store-id SSLCOMMERZ_STORE_PASSWD=your-sandbox-store-password SSLCOMMERZ_SANDBOX=true WEB_URL=http://localhost:3000 pnpm devand sign in.- Visit
/papers→ tap a premium paper → click "Unlock with Premium" → choose a plan → you'll be redirected tosandbox.sslcommerz.com. - Use the SSL Commerz sandbox test card (
4111111111111111/ any future expiry / any 3-digit CVV). - After paying, you'll be redirected to
/payment/success?tran_id=.... The page polls the server every 2s until the webhook fires and flips yourisPremium=true. - Verify in the DB:
User.premiumUntilshould be ~30 or 365 days in the future.
Mobile
The mobile paywall screen (apps/mobile/app/(app)/paywall.tsx) opens the web /pricing page in the device browser via Linking.openURL. After paying on the web, return to the app — the next request to a premium paper will see your newly-granted entitlement.
Going live
- Flip
SSLCOMMERZ_SANDBOX=falseto use the production gateway (securepay.sslcommerz.com). - Set
WEB_URLto your deployed web origin (used to build the absolute success/fail/cancel redirect URLs). - Add an IPN allowlist on the SSL Commerz dashboard for your API host's egress IPs.
- Stripe is supported by the validators + enums but the route is not wired — that's a follow-up.
Offline + Push + Realtime (Phase 6)
Phase 6 ships three features that close the "production-ready" gap.
Offline exam answer queue
The mobile exam screen's autosave is durable — if the network drops, the answer is enqueued in expo-secure-store and flushed on reconnect. To test:
- Start the mobile build (
pnpm --filter @elite/mobile start), sign in, open a paper. - Answer 3 questions (autosave fires every 800 ms).
- Toggle airplane mode on the device.
- Answer 2 more questions. The exam screen shows an amber "Offline — N queued" banner and no error.
- Toggle airplane mode off. Within ~1 s, the banner flips to "Syncing…" then disappears. Verify the 5 answers landed in the DB (
SELECT * FROM "Answer" WHERE ...). - Tap Submit. The queue is drained first, so the submit endpoint grades over the most recent state.
The queue is per-attempt (one SecureStore key per attemptId) to stay under iOS's 2 KB per-key limit. Per-item 4xx errors drop stale rows; per-item 5xx/network errors preserve the rest of the queue.
Push notifications (Expo Push)
The mobile app requests notification permission on SIGNED_IN and POSTs the Expo push token to /api/v1/devices. The server fans out notifications after each exam submission:
- Friend activity — anyone on the same Board who has a registered push device gets a
FRIEND_ACTIVITYnotification ("Tasnim just completed a paper — 92%"). Capped at 50 peers per submission. - Rank change — if your paper-leaderboard rank jumped by ≥3 positions, you get a
RANK_CHANGEnotification + push. - Both fan-outs are batched into a single Expo Push request (free tier allows 1 push/sec aggregate per project).
To enable real pushes:
- Create an Expo account at https://expo.dev and an EAS project for the mobile app.
- Set
eas.projectIdinapp.jsonto your real project ID (replace the placeholder00000000-0000-0000-0000-000000000000). - For FCM (Android), upload
google-services.jsonand configure FCM via the Expo push service (or use the Expo Push Tool to test without a real APK). - For APNs (iOS), set up Apple push credentials via EAS.
- (Optional) Set
EXPO_ACCESS_TOKENin.envfor higher rate limits.
To exercise the path locally without a real device, use the Expo Push Tool at https://expo.dev/notifications. The expo-notifications plugin is already added to app.json.
Realtime leaderboard + notifications
We use Supabase Realtime for both leaderboard updates and notification inbox refreshes:
- Leaderboard — server POSTs to
/realtime/v1/api/broadcaston thelb:updatestopic after each attempt submit. Both web (useRealtimeLeaderboard) and mobile (useRealtimeLeaderboardInvalidator) subscribe and refetch on every event. - Notifications — Postgres row-level channel on the
notificationstable, filtered byuser_id. New rows prepend to the inbox + bump the bell badge without a refresh.
Realtime requires the existing Supabase project; no new infra. To test side by side:
- Open
/leaderboardson web + open the mobile leaderboards tab. - Sign in as a third user on a different browser/device, complete an exam.
- Both UIs should refresh within ~1 s.
Realtime channel names (for power-users)
| Channel | Direction | Payload | Filter |
|---|---|---|---|
lb:updates (broadcast) | Server → clients | { scope, period, userId, score, rank, changedAt } | none |
public:notifications (postgres_changes) | Server → clients | full row | user_id=eq.<userId> |
Topic + event names live in packages/shared-types/src/realtime.ts so all three packages stay in sync.
Observability (Phase 7)
Sentry + PostHog are wired on the backend, web, and mobile. Both SDKs are feature-detected — leave the env keys blank and the helpers no-op, the app keeps working as before. This is the recommended pattern when you don't have prod traffic yet and don't want to pay for two SaaS that you're not yet looking at.
What gets captured
Sentry (5xx errors only — 4xx is intentionally not captured, since it represents client errors, not server bugs):
| Surface | SDK | Where errors come from |
|---|---|---|
| Backend API | @sentry/node | app.ts setErrorHandler — captures with { requestId, userId, route, statusCode } extras. Auth middleware also calls setUser({ id, email, role }) so every breadcrumb carries the actor. |
| Web | @sentry/nextjs | Auto-captured from React + Next.js boundaries. Sent on both client (sentry.client.config.ts) and server (sentry.server.config.ts) runtime. |
| Mobile | sentry-expo | Auto-captured JS errors + native crashes. release is the bundled version. |
tracesSampleRate is 0.1 in prod, 1.0 in dev. Session replay is off on all three surfaces (replaysSessionSampleRate: 0) — happy to revisit if product asks for it.
PostHog (5 typed server funnel events + client-side identify):
| Event | When | Props |
|---|---|---|
signup_completed | First time POST /auth/sync sees this Supabase user | source: 'email' | 'phone' | 'google' | 'facebook' |
login_completed | Subsequent POST /auth/sync for an existing user | source |
exam_started | POST /attempts creates an attempt | paperId, paperType |
exam_submitted | POST /attempts/:id/submit succeeds | paperId, score, percentage, timeTakenSec |
payment_initiated | POST /payments/init creates a Payment row | plan, amountBdt, gateway: 'sslcommerz' |
payment_succeeded | SSL Commerz webhook marks the payment complete | plan, amountBdt, tranId |
All events carry $user_id = the actor's UUID, so PostHog's funnel reports work out of the box. The web + mobile PostHog clients identify with the same Supabase userId on SIGNED_IN, so the user's Activity feed joins the server funnel stream and the client device stream into a single timeline. reset() on SIGNED_OUT prevents identity leakage between accounts on the same device.
Provisioning (when you decide to turn it on)
-
Create a Sentry project. Copy the DSN. Repeat for web + mobile (3 projects — backend, web, mobile — or 1 shared project if you prefer).
-
Create a PostHog project. Copy the project API key + the ingest host (US cloud by default:
https://us.i.posthog.com). -
Fill the env keys (all optional — leave blank to keep the no-op default):
# .env (backend + web preview) SENTRY_DSN=https://...@sentry.io/... NEXT_PUBLIC_SENTRY_DSN=https://...@sentry.io/... EXPO_PUBLIC_SENTRY_DSN=https://...@sentry.io/... POSTHOG_API_KEY=phc_... POSTHOG_HOST=https://us.i.posthog.com NEXT_PUBLIC_POSTHOG_KEY=phc_... NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com EXPO_PUBLIC_POSTHOG_KEY=phc_... EXPO_PUBLIC_POSTHOG_HOST=https://us.i.posthog.comThe prefixes (
NEXT_PUBLIC_,EXPO_PUBLIC_) are required — web and mobile only inline env vars that are so prefixed. -
pnpm devand trigger an error. Within ~10 s you should see the issue in Sentry with request + user context. -
Sign up a new user, start an exam, submit, then pay. Within ~30 s PostHog Activity should show 6 events for that user.
Admin KPI endpoint
GET /api/v1/admin/stats (admin role required) returns:
{
"totalUsers": 142,
"premiumUsers": 23,
"attempts30d": 892,
"revenue30dBdt": 18450,
"generatedAt": "2026-07-14T12:34:56.789Z"
}
The /admin dashboard polls this every 60 s and renders 7 tiles (the original 3 zero tiles + a new Total Users tile, plus the 3 pre-existing "papers / questions / users with attempts" tiles). All aggregates hit indexed columns, including a new @@index([createdAt]) on Payment to keep the 30-day revenue query cheap.
Verify manually
GET /api/v1/admin/statsas a non-admin → 403.GET /api/v1/admin/statsas admin → 200 with all 4 numbers populated.- Cross-check against
SELECT count(*) FROM "User", etc. - Mobile: build the app, throw a test error, watch Sentry show it as a
javascriptissue.
What's NOT here yet
- Source-map upload from CI (would let Sentry show unminified stacks for prod errors). Defer until the CI release pipeline exists.
- Client-side funnel events (
paper_viewed,option_selected, etc.). The 5 server events tell the funnel story end-to-end already; finer-grained client events are deferred.
Security (Phase 7.5)
Phase 7.5 ships three backend-only hardening features. No client changes required.
Per-route rate-limits
The global @fastify/rate-limit middleware (plugins/rate-limit.ts, 200 req/min/IP) protects every endpoint, but a single noisy client can still exhaust an obvious chokepoint. Phase 7.5 overlays per-route caps on four routes:
| Route | Limit | Key |
|---|---|---|
POST /api/v1/auth/sync | 30 / min | IP |
POST /api/v1/payments/init | 5 / min | req.user.id |
POST /api/v1/devices | 10 / min | req.user.id |
POST /webhooks/sslcommerz | 60 / min | IP |
The user-keyed limits run at the preHandler hook so req.user is populated by the auth preHandler before the keyGenerator fires. DELETE /devices/:token deliberately stays on the global limit so legitimate sign-out traffic is never throttled.
Define overrides in services/api/src/lib/rate-limit-presets.ts:
import { RATE_LIMITS } from '../lib/rate-limit-presets';
app.post('/auth/sync', {
preHandler: app.auth.authenticate,
config: { rateLimit: RATE_LIMITS.AUTH_SYNC },
}, handler);
GDPR data export
GET /api/v1/me/export (auth required) returns a single JSON dump of every owned row (profile + attempts + answers + bookmarks + notifications + payments + devices). Browser receives the payload as a downloaded file:
curl -H "Authorization: Bearer <token>" \
http://localhost:4000/api/v1/me/export \
-o elite-academy-export-2026-07-14.json
The endpoint sets Content-Disposition: attachment; filename="elite-academy-export-<userId>-<date>.json". The payload is small for any single user (MB tops out around the threshold for a power user with thousands of answers); if we ever serve a user with millions of rows we'd switch to a streaming JSON encoder.
Hard-delete sweeper
DELETE /api/v1/auth/account continues to soft-delete (User.deletedAt = now()). After a 30-day grace period, a background sweeper hard-deletes the row — and the cascading FKs (schema.prisma) wipe Attempt / Answer / Bookmark / Payment / Notification / UserDevice in one statement. AuditLog.actor was switched from Cascade to SetNull so the "what happened to which entity, when" trail survives the wipe; the actor identity is the only thing lost.
The sweeper runs hourly from jobs/scheduler.ts via a single setInterval. .unref() keeps the interval from holding the event loop open on its own. Grace period is env-tunable via HARD_DELETE_GRACE_DAYS (default 30; set to 0 in dev to test without waiting):
# .env — wipe immediately on next tick (dev only!)
HARD_DELETE_GRACE_DAYS=0
Stop the sweeper before tear-down: the SIGTERM/SIGINT handler in app.ts calls scheduler.stop() first, so no new tick can kick off mid-shutdown.
What's NOT here yet
- CSRF — not needed. The API is bearer-only (Supabase JWT HS256-verified via
SUPABASE_JWT_SECRET); the web client uses@supabase/ssrcookies with same-origin middleware. Both are CSRF-safe by construction. - Webhook signature verification / IP allowlist — SSL Commerz already has zod validation + idempotency + validator-API re-verify. IP allowlists are brittle (multiple POPs) and the gateway's actual rate is well below our 60/min limit.
- Prometheus
/metricsendpoint — defer until there's real traffic worth scraping.
Localization (Phase 8 — Bangla i18n)
UI chrome is fully translated to Bangla (bn) on both web and mobile. Source of truth is User.preferredLanguage on the DB row; a footer "বাংলা / English" link on the marketing pages (and a language picker in the mobile profile screen) lets users toggle.
How the locale is resolved
- Web —
next-intlgetRequestConfigreads in this order: cookie →User.preferredLanguage(server-fetched on each layout) →NEXT_PUBLIC_DEFAULT_LOCALE. Falls back toenif a key is missing inbn.json. - Mobile — two-tier init. On mount,
initI18n(detectDeviceLocale())paints the UI immediately. Once theuseUserProfile()query resolves, a<LocaleSyncer>callssetLocale(profile.preferredLanguage)to upgrade. Both calls are idempotent.
Endpoints
GET /api/v1/me/profile → { id, name, email, phone, preferredLanguage, avatarUrl }
PATCH /api/v1/me/language → { preferredLanguage: 'en' | 'bn' }
Zod on the PATCH rejects anything outside en | bn with code: VALIDATION_ERROR.
Switching the language from the app
- Web — click the
<LocaleSwitcher />in the marketing footer or the app header. Writes the cookie + callsPATCH /me/languageif the user is signed in. Reload-free; the entire UI re-renders. - Mobile — Profile screen → pick "English" or "বাংলা". Calls
changeLanguage('bn')which PATCHes the server, then updates i18next + the cached profile in one step.
Bangla font + numerals
- Web —
<html lang={locale}>. Body getsfont-bangla(Tailwind utility pointing at the already-loadedNoto_Sans_BengaliNext.js font) when locale isbn. Exam screen question content already usedfont-bangla; UI chrome now joins it. - Mobile —
Intl.NumberFormat('bn-BD')informatBdt()shows Bengali digits for currency. Bangla script renders natively on Android via the system font; no extra Expo font for v1. - Currency example —
formatBdt(1999, 'bn-BD')→৳১,৯৯৯(same as web).
File layout
apps/web/
├── messages/
│ ├── en.json (~370 keys)
│ └── bn.json (mirror; structural drift allowed for v1)
├── i18n/
│ ├── request.ts # next-intl getRequestConfig
│ ├── locale.ts # SUPPORTED_LOCALES + isLocale()
└── components/
└── locale-switcher.tsx # client component
apps/mobile/
└── lib/
├── i18n/
│ ├── index.ts # initI18n, setLocale, detectDeviceLocale
│ ├── en.json (~370 keys)
│ └── bn.json (mirror)
└── hooks/
└── useUserProfile.ts
What's deferred
- Push notification copy in Bangla (
Notification.title/body) — server-side template work. /[locale]/URL prefix routing —localePrefix: 'never'for now.- Admin chrome translation (
/admin/**) — internal-only.
NPM scripts
| Command | What it does |
|---|---|
pnpm dev | Runs web, API, and mobile dev servers in parallel |
pnpm dev:web / dev:api / dev:mobile | Run a single app |
pnpm build | Production build for all apps |
pnpm lint | ESLint across the monorepo |
pnpm typecheck | TypeScript check across the monorepo |
pnpm test | Run unit tests (Vitest/Jest) |
pnpm format | Prettier write |
pnpm clean | Remove all build artifacts + node_modules |
Development phase tracker
See PHASES.md for the live status of every phase (0 → 8) and milestone checklist.
Documentation
docs/architecture.md— system architecture, modules, data flowPHASES.md— full development plan with phase tracker
License
UNLICENSED — private project.