Architecture Overview
How OptiLearn is structured and how it integrates with OptiCRM
OptiLearn is a Next.js 14 App Router application using TypeScript, Prisma, and PostgreSQL. It runs as a separate service alongside OptiCRM, sharing authentication via cross-subdomain JWT cookies.
Tech Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 14 App Router |
| Language | TypeScript strict mode |
| UI | shadcn/ui + Tailwind CSS v4 |
| Database | PostgreSQL 16 |
| ORM | Prisma 6 |
| Auth | NextAuth v5 beta |
| File Storage | Cloudflare R2 (S3-compatible) |
| Cache/Queues | Redis + BullMQ |
| Charts | Recharts |
| Rich Text | TipTap |
| Video | react-player |
| Resend (SMTP fallback) | |
| Push | Firebase Cloud Messaging |
| i18n | next-intl (web), flutter_localizations (mobile) |
| Hosting | Coolify on Hostinger VPS |
Repository Structure
optilearn/
├── prisma/
│ └── schema.prisma # 60+ models
├── scripts/
│ └── worker.ts # Long-lived BullMQ worker process
├── src/
│ ├── app/
│ │ ├── (app)/ # Main app pages (authenticated)
│ │ ├── (auth)/login/
│ │ ├── api/ # All API routes
│ │ ├── certificate/verify/
│ │ └── docs/ # This documentation
│ ├── components/
│ ├── content/docs/ # MDX documentation files
│ ├── i18n/ # next-intl config (cookie-driven)
│ ├── lib/
│ │ ├── notifications/ # NotificationService + queues + workers
│ │ ├── analytics/ # Report DSL + executor
│ │ ├── captions/ # WebVTT parsing helpers
│ │ ├── opticrm-api.ts # Cross-service client
│ │ ├── parent-links.ts # Parent ↔ student via OptiCRM
│ │ ├── peer-review.ts # Reviewer assignment + aggregation
│ │ ├── drip.ts # Lesson unlock enforcement
│ │ ├── fee-gating.ts # OPTIONAL fee block (Track D)
│ │ ├── class-targeting.ts # Class/section restriction
│ │ ├── jitsi-recording.ts # Jibri start/stop wrapper
│ │ └── …
│ └── middleware.ts # Auth + tenant + security headers
└── messages/ # next-intl JSON bundles (en, hi)
Multi-Tenancy
OptiLearn uses row-level isolation via institutionId:
- Every Prisma model has an
institutionIdcolumn (indexed) - Every query filters by
institutionId - Institution context comes from the validated JWT, never from request body
- Different institutions' data is completely isolated even though they share a single database
There's a helper in src/lib/tenant.ts:
const user = await requireAuth();
// user.institutionId, user.institutionSlug, user.role, user.isContact
URL Routing — Multi-Tenant OptiCRM Calls
OptiCRM is multi-tenant via subdomain — each institution lives at <slug>.opticrm.app and the apex opticrm.app doesn't resolve. Every cross-service call from OptiLearn must therefore be routed to the tenant's own subdomain.
OPTICRM_BASE_DOMAIN lets us swap the parent domain (default opticrm.app). The client in src/lib/opticrm-api.ts builds https://<slug>.<OPTICRM_BASE_DOMAIN> for every request and sends x-institution-slug as a belt-and-braces header. See OptiCRM Integration for the full surface.
Auth Flow (Shared SSO with OptiCRM)
OptiLearn and OptiCRM share authentication via:
- Same NEXTAUTH_SECRET — both apps validate the same JWT signing key
- Cookie domain
.opticrm.app— coverslearn.opticrm.appand all OptiCRM subdomains - Same cookie name —
__Secure-authjs.session-token(prod) /authjs.session-token(dev) - JWT token fields —
{id, email, name, role, institutionId, institutionSlug, isContact, contactId, contactType}
When a user logs into springdale.opticrm.app, they get a cookie set on .opticrm.app. When they visit learn.opticrm.app, OptiLearn's middleware reads the same cookie and extracts the institution context.
Two User Types
OptiCRM has two distinct user types that OptiLearn respects:
- User — Staff, instructors, admins. Use OptiCRM's
Usermodel. - Contact — Students and parents (portal users). Use OptiCRM's
Contactmodel withcontactType: CONTACT_STUDENT | CONTACT_PARENT.
The JWT has an isContact boolean and contactType to differentiate them. OptiLearn uses this to:
- Show instructor / student / parent dashboards
- Gate management actions (only Users can create courses)
- Drive
normaliseRole()so the notification preferences page shows the right key set per audience
API Response Shape
All API routes return a consistent shape:
// Success
{ data: T, error: null, meta?: { total, page, perPage, totalPages } }
// Error
{ data: null, error: { code: string, message: string, details?: unknown } }
Helpers in src/types/api.ts: successResponse, errorResponse, paginatedResponse.
File Uploads (R2)
Files go directly from browser to Cloudflare R2 using presigned URLs:
- Browser → POST
/api/uploadwith filename, content type, category - Server validates and generates a presigned PUT URL using
aws4signing - Browser PUTs the file directly to R2
- Browser saves the public URL (e.g.
https://files.opticrm.app/lms/...) in the database
Storage layout:
lms/<institutionId>/courses/<courseId>/<category>/<file>
lms/<institutionId>/courses/<courseId>/lessons/<lessonId>/<category>/<file>
lms/<institutionId>/<category>/<file>
Caption (.vtt) files use the captions category and a server-side PUT (the file is small text). See src/lib/r2.ts.
Notifications System
A unified, role-aware engine for every email, push, and in-app message OptiLearn sends. Highlights:
- 31 notification keys registered in
src/lib/notifications/notification-definitions.ts— student, teacher, parent, admin NotificationService.send / sendBulk / queueForDigestis the single entry point- 5 suppression rules — bounce, duplicate, daily cap, re-engage cooldown, quiet hours
- Handlebars templates with shared helpers (firstName, courseUrl, formatDate, unsubscribeUrl)
- 5 BullMQ queues processed by a separate worker (
scripts/worker.ts) - Repeatable crons inside the worker — digest scan (15 min), reengage (6 h), due-reminder (1 h)
Full reference: Notifications System. Worker deployment: Worker Deployment. HTTP surface: Notifications API.
OptiCRM Integration Surface
Nine /api/lms/* endpoints on the OptiCRM side back the LMS:
| Path | Purpose |
|---|---|
/api/lms/contact/[id] | Contact lookup (email, name) |
/api/lms/student/[id] | Student lookup (incl. currentEnrolment) |
/api/lms/student/[id]/parents | Parent links (drives parent notifications) |
/api/lms/user/[id] | Staff user lookup |
/api/lms/classes | Class + section catalogue (Track C) |
/api/lms/sections/[id]/students | Section roster |
/api/lms/students/[id]/fee-status | Fee status (Track D, no-store) |
/api/lms/students/[id]/attendance | Optional attendance feed |
/api/lms/parent/[id]/children | Children linked to a parent (no-store) |
/api/lms/completion | OUTBOUND — push course completion to OptiCRM |
See OptiCRM Integration for client patterns, auth, and caching.
Drip Content
src/lib/drip.ts decides whether a lesson is currently available to a student. Two modes:
- Absolute —
lesson.unlockAt(timestamp); wins when set - Relative —
lesson.unlockAfterDays(N days after enrollment.createdAt)
enforceLessonUnlocked(lessonId, studentId) throws LessonLockedError from any handler that opens lesson content (the player, progress save, quiz attempt). The check short-circuits when neither field is set so most lessons cost zero extra DB calls.
Peer Reviews
src/lib/peer-review.ts owns reviewer assignment + aggregation:
- On submit, picks up to
peerReviewCountreviewers from peers who have themselves submitted; ties broken by fewest pending reviews - Cron-friendly
backfillPeerReviews()tops up shortfalls when more peers submit later recomputeSubmissionPeerScore(submissionId)rolls per-reviewer scores intosubmission.peerScoreAverageand, on the last review, setssubmission.scoreif the instructor hasn't manually graded- Fires
PEER_REVIEW_ASSIGNEDto reviewers andASSESS_PEER_GRADEDto the original student exactly once
Endpoints: Peer Reviews API.
Parent Flow
Parents are Contacts with contactType = CONTACT_PARENT. OptiLearn never owns the parent↔student graph — that's ContactStudentLink in OptiCRM, exposed via /api/lms/parent/[id]/children and /api/lms/student/[id]/parents.
/api/parent/childrenreturns the parent's own children (cache: no-store)/api/parent/children/[childId]/overviewis the read-only dashboard payload — courses, recent grades, upcoming work, live class attendance, gamification snapshot, certificates- Server enforces ownership by always re-resolving the children list from OptiCRM rather than trusting the request URL
- Notification side:
getParentsForStudent()insrc/lib/parent-links.tspowers fan-out forPARENT_CHILD_GRADED,PARENT_CHILD_LOW_GRADE,PARENT_CHILD_ABSENT,PARENT_WEEKLY_DIGEST
Endpoints: Parent API. Admin setup: Parent Portal.
Fee Gating (Optional)
src/lib/fee-gating.ts blocks students with OVERDUE fees from enrolling or accessing lessons, when the institution has flipped LMSSettings.feeGatingEnabled on. Notes:
- Default OFF — most institutions don't enable it
- Soft-fails OPEN when OptiCRM bridge is down (a school must never be fully bricked by a transient outage)
- React
cache()memoises the OptiCRM round-trip per request
Admin setup: Fee Gating.
Class & Section Targeting
src/lib/class-targeting.ts filters Course and CourseAnnouncement rows whose targetClassIds / targetSectionIds are non-empty against the student's currentEnrolment from OptiCRM. Empty arrays mean "no targeting".
Live Sessions & Recording
OptiLearn embeds Jitsi Meet for live classes. Recording is wired through Jibri (Jitsi Broadcasting Infrastructure):
POST /api/live-sessions/[id]/recording/start— host-only, calls Jibri viasrc/lib/jitsi-recording.tsPOST /api/live-sessions/[id]/recording/stop— host-only, idempotentPOST /api/webhooks/jitsi-recording— public Jibri callback, HMAC-SHA256 verified, fansLIVE_CLASS_RECORDING_READYout to enrolled students
The OptiLearn side just wires the UI; provisioning the Jibri sidecar in your Coolify Jitsi deployment is left as ops work — see Jibri Recording Setup.
Internationalization
Web uses next-intl with a cookie-driven locale (no /en /hi URL prefix), so SSO callbacks and bookmarked links keep working. Mobile uses flutter_localizations with ARB files. See i18n for both.
Analytics Engine
The analytics surface (dashboards, custom reports, exports, schedules, alerts, caching, snapshots) is documented standalone — see Analytics Engine.
Critical Rules
- Every Prisma query filters by institutionId — always
- institutionId comes from JWT, never from request body
- TypeScript strict mode — no
anytypes - All inputs validated with Zod
- Server components for data fetching — client components only for interactivity
- Check
user.isContact+user.contactTypeto choose dashboard / preference set - Never call OptiCRM without
institutionSlug— apexopticrm.appdoesn't resolve