DocsDeveloper

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

LayerTechnology
FrameworkNext.js 14 App Router
LanguageTypeScript strict mode
UIshadcn/ui + Tailwind CSS v4
DatabasePostgreSQL 16
ORMPrisma 6
AuthNextAuth v5 beta
File StorageCloudflare R2 (S3-compatible)
Cache/QueuesRedis + BullMQ
ChartsRecharts
Rich TextTipTap
Videoreact-player
EmailResend (SMTP fallback)
PushFirebase Cloud Messaging
i18nnext-intl (web), flutter_localizations (mobile)
HostingCoolify 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 institutionId column (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:

  1. Same NEXTAUTH_SECRET — both apps validate the same JWT signing key
  2. Cookie domain .opticrm.app — covers learn.opticrm.app and all OptiCRM subdomains
  3. Same cookie name__Secure-authjs.session-token (prod) / authjs.session-token (dev)
  4. 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 User model.
  • Contact — Students and parents (portal users). Use OptiCRM's Contact model with contactType: 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:

  1. Browser → POST /api/upload with filename, content type, category
  2. Server validates and generates a presigned PUT URL using aws4 signing
  3. Browser PUTs the file directly to R2
  4. 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 / queueForDigest is 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:

PathPurpose
/api/lms/contact/[id]Contact lookup (email, name)
/api/lms/student/[id]Student lookup (incl. currentEnrolment)
/api/lms/student/[id]/parentsParent links (drives parent notifications)
/api/lms/user/[id]Staff user lookup
/api/lms/classesClass + section catalogue (Track C)
/api/lms/sections/[id]/studentsSection roster
/api/lms/students/[id]/fee-statusFee status (Track D, no-store)
/api/lms/students/[id]/attendanceOptional attendance feed
/api/lms/parent/[id]/childrenChildren linked to a parent (no-store)
/api/lms/completionOUTBOUND — 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:

  • Absolutelesson.unlockAt (timestamp); wins when set
  • Relativelesson.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 peerReviewCount reviewers 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 into submission.peerScoreAverage and, on the last review, sets submission.score if the instructor hasn't manually graded
  • Fires PEER_REVIEW_ASSIGNED to reviewers and ASSESS_PEER_GRADED to 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/children returns the parent's own children (cache: no-store)
  • /api/parent/children/[childId]/overview is 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() in src/lib/parent-links.ts powers fan-out for PARENT_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 via src/lib/jitsi-recording.ts
  • POST /api/live-sessions/[id]/recording/stop — host-only, idempotent
  • POST /api/webhooks/jitsi-recording — public Jibri callback, HMAC-SHA256 verified, fans LIVE_CLASS_RECORDING_READY out 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

  1. Every Prisma query filters by institutionId — always
  2. institutionId comes from JWT, never from request body
  3. TypeScript strict mode — no any types
  4. All inputs validated with Zod
  5. Server components for data fetching — client components only for interactivity
  6. Check user.isContact + user.contactType to choose dashboard / preference set
  7. Never call OptiCRM without institutionSlug — apex opticrm.app doesn't resolve