WebNest
Team/Md Abir Hossain/elite-academy

Repository

elite-academy

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.

View on GitHub ↗
TypeScript0 stars0 forks

README

Elite Academy

Bangladesh-focused board exam (HSC/SSC) preparation platform — Next.js web, Expo React Native Android, and a unified Node.js backend.

Status: Phase 7 — Hardening Shipped (7.5 Security Done)


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

LayerTech
WebNext.js 14 App Router · TypeScript · Tailwind CSS · shadcn/ui
AdminNext.js /admin route group with role-gated middleware
MobileReact Native · Expo SDK 51 · Expo Router · NativeWind v4
APINode.js · Fastify · TypeScript · Prisma ORM · Zod
DBPostgreSQL (Supabase)
AuthSupabase Auth (email/password, phone OTP, Google, Facebook)
StorageSupabase Storage
RealtimeSupabase Realtime
Cache/QueueUpstash Redis · BullMQ
PushExpo Push (FCM)
PaymentsSSL Commerz · Stripe · Google Play Billing
ObservabilitySentry · PostHog
CI/CDGitHub 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
AppLocal URL
Webhttp://localhost:3000
APIhttp://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:

PlanPriceDuration
Monthly৳29930 days
Yearly৳1,999365 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

  1. Sign up at https://developer.sslcommerz.com and grab your sandbox Store ID + Store Password.
  2. 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
    
  3. pnpm dev and sign in.
  4. Visit /papers → tap a premium paper → click "Unlock with Premium" → choose a plan → you'll be redirected to sandbox.sslcommerz.com.
  5. Use the SSL Commerz sandbox test card (4111111111111111 / any future expiry / any 3-digit CVV).
  6. After paying, you'll be redirected to /payment/success?tran_id=.... The page polls the server every 2s until the webhook fires and flips your isPremium=true.
  7. Verify in the DB: User.premiumUntil should 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=false to use the production gateway (securepay.sslcommerz.com).
  • Set WEB_URL to 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:

  1. Start the mobile build (pnpm --filter @elite/mobile start), sign in, open a paper.
  2. Answer 3 questions (autosave fires every 800 ms).
  3. Toggle airplane mode on the device.
  4. Answer 2 more questions. The exam screen shows an amber "Offline — N queued" banner and no error.
  5. 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 ...).
  6. 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_ACTIVITY notification ("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_CHANGE notification + 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:

  1. Create an Expo account at https://expo.dev and an EAS project for the mobile app.
  2. Set eas.projectId in app.json to your real project ID (replace the placeholder 00000000-0000-0000-0000-000000000000).
  3. For FCM (Android), upload google-services.json and configure FCM via the Expo push service (or use the Expo Push Tool to test without a real APK).
  4. For APNs (iOS), set up Apple push credentials via EAS.
  5. (Optional) Set EXPO_ACCESS_TOKEN in .env for 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/broadcast on the lb:updates topic after each attempt submit. Both web (useRealtimeLeaderboard) and mobile (useRealtimeLeaderboardInvalidator) subscribe and refetch on every event.
  • Notifications — Postgres row-level channel on the notifications table, filtered by user_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:

  1. Open /leaderboards on web + open the mobile leaderboards tab.
  2. Sign in as a third user on a different browser/device, complete an exam.
  3. Both UIs should refresh within ~1 s.

Realtime channel names (for power-users)

ChannelDirectionPayloadFilter
lb:updates (broadcast)Server → clients{ scope, period, userId, score, rank, changedAt }none
public:notifications (postgres_changes)Server → clientsfull rowuser_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):

SurfaceSDKWhere errors come from
Backend API@sentry/nodeapp.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/nextjsAuto-captured from React + Next.js boundaries. Sent on both client (sentry.client.config.ts) and server (sentry.server.config.ts) runtime.
Mobilesentry-expoAuto-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):

EventWhenProps
signup_completedFirst time POST /auth/sync sees this Supabase usersource: 'email' | 'phone' | 'google' | 'facebook'
login_completedSubsequent POST /auth/sync for an existing usersource
exam_startedPOST /attempts creates an attemptpaperId, paperType
exam_submittedPOST /attempts/:id/submit succeedspaperId, score, percentage, timeTakenSec
payment_initiatedPOST /payments/init creates a Payment rowplan, amountBdt, gateway: 'sslcommerz'
payment_succeededSSL Commerz webhook marks the payment completeplan, 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)

  1. Create a Sentry project. Copy the DSN. Repeat for web + mobile (3 projects — backend, web, mobile — or 1 shared project if you prefer).

  2. Create a PostHog project. Copy the project API key + the ingest host (US cloud by default: https://us.i.posthog.com).

  3. 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.com
    

    The prefixes (NEXT_PUBLIC_, EXPO_PUBLIC_) are required — web and mobile only inline env vars that are so prefixed.

  4. pnpm dev and trigger an error. Within ~10 s you should see the issue in Sentry with request + user context.

  5. 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/stats as a non-admin → 403.
  • GET /api/v1/admin/stats as 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 javascript issue.

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:

RouteLimitKey
POST /api/v1/auth/sync30 / minIP
POST /api/v1/payments/init5 / minreq.user.id
POST /api/v1/devices10 / minreq.user.id
POST /webhooks/sslcommerz60 / minIP

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/ssr cookies 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 /metrics endpoint — 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

  1. Webnext-intl getRequestConfig reads in this order: cookie → User.preferredLanguage (server-fetched on each layout) → NEXT_PUBLIC_DEFAULT_LOCALE. Falls back to en if a key is missing in bn.json.
  2. Mobile — two-tier init. On mount, initI18n(detectDeviceLocale()) paints the UI immediately. Once the useUserProfile() query resolves, a <LocaleSyncer> calls setLocale(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 + calls PATCH /me/language if 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 gets font-bangla (Tailwind utility pointing at the already-loaded Noto_Sans_Bengali Next.js font) when locale is bn. Exam screen question content already used font-bangla; UI chrome now joins it.
  • MobileIntl.NumberFormat('bn-BD') in formatBdt() shows Bengali digits for currency. Bangla script renders natively on Android via the system font; no extra Expo font for v1.
  • Currency exampleformatBdt(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

CommandWhat it does
pnpm devRuns web, API, and mobile dev servers in parallel
pnpm dev:web / dev:api / dev:mobileRun a single app
pnpm buildProduction build for all apps
pnpm lintESLint across the monorepo
pnpm typecheckTypeScript check across the monorepo
pnpm testRun unit tests (Vitest/Jest)
pnpm formatPrettier write
pnpm cleanRemove all build artifacts + node_modules

Development phase tracker

See PHASES.md for the live status of every phase (0 → 8) and milestone checklist.

Documentation

License

UNLICENSED — private project.

← Back to profile