Skip to content
MithunKumar09Public

About

Production-ready multi-tenant education platform built with React, TypeScript, Node.js, Express and MongoDB. Supports organization-scoped access, multi-role dashboards, course lifecycle, enrollments, progress, Razorpay/offline payments, MFA-secured auth, SSE notifications, certificates, notes, reviews and audit-aware workflows.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

MYGangoor Foundation (MYGf) — Multi-Tenant Education & Course Management Platform (Online & Offline)

M. Y. Gangoor Global Foundation — Multi-Sciences Education and Research Centre "Providing quality education and research opportunities since 1995."

A multi-tenant learning-management, admissions, and payments platform built as a React 19 single-page application backed by an Express 5 / MongoDB REST API.


Table of Contents

  1. Overview
  2. Main Purpose
  3. Technology Stack
  4. User Roles & Permissions
  5. Architecture
  6. Data Model
  7. Comprehensive Feature Set
  8. Built but Not Currently Exposed
  9. Configuration & Deployment
  10. Known Inconsistencies

1. Overview

MYGf stands for M. Y. Gangoor Foundation. The full product name, taken from the SEO metadata in mygf/index.html, is "MYGangoor Foundation — M. Y. Gangoor Global Foundation's Multi-Sciences Education and Research Centre."

Naming note. You will encounter the internal codename "ECA" scattered through the codebase — in localStorage keys (eca:auth:terminated), in the archived mygf/eca.zip, and in some comments. It refers to this same product. There is no separate "ECA" application.

What it is

MYGf is a multi-tenant education platform. A single foundation operates a number of physical and online learning centers; each center is modeled in the system as an Organization. Every organization has its own administrators, teachers, students, courses, and revenue — all isolated from other organizations by an orgId scope — while the foundation itself retains a platform-wide superadmin view across all of them.

The business problem it solves

Running a network of learning centers means running several systems at once, and most off-the-shelf LMS products only solve the first one:

Problem How MYGf addresses it
Students need to discover and buy courses Public catalog at /tracks with search, filters, previews, and wishlist
Enrollment happens both online and at a physical desk Dual payment rails — Razorpay online and offline cash with receipt verification
Families often cannot pay the full fee upfront First-class partial payment support with tracked remaining balances
Cash payments create duplicate/ambiguous records An automated reconciliation service that matches duplicates
Each center is owed its share of collected revenue A payout ledger that settles captured payments per organization
Learners need materials, progress, and proof of completion PDF/rich-text notes with a flipbook reader, per-chapter progress, and generated PDF certificates

Who uses it

Four functional personas: the platform owner (superadmin), the center administrator (admin), the instructor (teacher), and the learner (student / orguser). Section 4 covers these in full.

Money

All monetary values are stored as integer paise (the minor unit of the Indian Rupee) throughout Payment, Course, and Payout. A course priced at ₹499 is stored as 49900. Currency is INR by default. Never treat these fields as rupees.


2. Main Purpose

The platform exists to manage the full lifecycle of a paying learner, plus the parallel financial lifecycle of the money they pay. These two tracks run alongside each other and meet at the moment of payment verification.

The learner track

Discover  →  Join       →  Pay        →  Enroll    →  Learn      →  Complete   →  Review
(/tracks)    (4-step       (online or    (idempotent  (chapters,    (progress,    (rating +
             wizard)       offline)      upsert)      notes,        certificate)  comment)
                                                      assessments)

A prospective student browses the public catalog, opens the Join Now wizard, supplies their details, and pays — either online through Razorpay or offline in cash at a center. Payment triggers enrollment. Enrollment unlocks course chapters, study notes, and assessments. Chapter-by-chapter progress accumulates until the course is complete, at which point a PDF certificate is generated. The student can then leave a review.

The money track

Payment created  →  Verified          →  Captured  →  Reconciled     →  Settled
(pending /          (admin clicks        (funds        (duplicate        (paid out to
 pending_veri-      "Verify & Enroll"    confirmed)    records auto-     the organization)
 fication)          or webhook fires)                  matched & merged)

An offline cash payment lands as pending_verification and waits for an administrator. An online payment is verified automatically by Razorpay HMAC signature and webhook. Once captured, the payment is eligible for reconciliation (if a duplicate record exists) and finally for inclusion in a payout to the organization that earned it.

The two tracks are joined by enrollmentService.js: the instant a payment reaches captured, an idempotent enrollment upsert runs. If that upsert fails, the payment is flagged needsEnrollment and a background job retries it every two minutes — so a student never pays without eventually getting access.


3. Technology Stack

The repository holds two independent applications with no monorepo tooling (no workspaces, no Turborepo, no Lerna). Each has its own package.json and its own deployment target.

Frontend — mygf/

React 19 SPA built with Vite, written in TypeScript, styled with Tailwind CSS v4.

Concern Library Version
UI framework react / react-dom ^19.1.1
Build tool vite ^7.1.0
Language typescript ~5.8.3
Styling tailwindcss + @tailwindcss/vite ^4.1.11
Routing react-router-dom ^7.8.0
Client state zustand ^5.0.7
Server state @tanstack/react-query ^5.85.5
HTTP client axios ^1.11.0
Animation framer-motion ^12.23.12
Icons lucide-react ^0.542.0
Icons (legacy) @fortawesome/react-fontawesome ^0.2.3
OAuth @react-oauth/google ^0.12.2
Toasts react-hot-toast ^2.6.0
Flipbook reader react-pageflip ^2.0.3
PDF rendering pdfjs-dist ^5.4.149
PDF generation jspdf + html2canvas ^3.0.2 / ^1.4.1
Spreadsheets xlsx (SheetJS) ^0.18.5
Class merging classnames ^2.5.1

Tailwind v4 is configured CSS-first (@import "tailwindcss" in mygf/src/index.css) via the Vite plugin — there is no tailwind.config.js.

Notably absent: there is no date library and no form/validation library on the frontend. Forms — including the multi-step join wizard — are hand-rolled with local state.

Backend — backend/

Express 5 REST API over MongoDB via Mongoose 8. ESM throughout. Entry point backend/server.js.

Concern Library Version
Web framework express ^5.1.0
ODM / database mongoose ^8.17.2
Auth tokens jsonwebtoken ^9.0.2
Password hashing bcrypt ^6.0.0
TOTP 2FA speakeasy ^2.0.0
QR codes qrcode ^1.5.4
Security headers helmet ^8.1.0
Rate limiting express-rate-limit ^8.0.1
Cookies cookie-parser ^1.4.7
CORS cors ^2.8.5
File uploads multer ^2.0.2
HTML sanitizing sanitize-html ^2.17.0
Media storage cloudinary ^2.7.0
PDF rendering puppeteer / puppeteer-core ^21.3.8 / ^24.21.0
Serverless Chromium @sparticuz/chromium ^138.0.2
Templating handlebars ^4.7.8
Email (primary) resend ^4.8.0
Email (alternates) nodemailer, mailgun.js ^7.0.5 / ^12.1.1
Dates dayjs ^1.11.9
Spreadsheets xlsx ^0.18.5
Config dotenv ^17.2.1

Payments are handled through Razorpay, called via raw fetch against https://api.razorpay.com/v1/... rather than through an SDK — see razorpayController.js. stripe and upi appear in the Payment.method enum as placeholders but are not implemented.

Tooling gaps worth knowing

These are stated plainly because they affect how you should work in this repo:

  • No test framework is configured in either application. There is no Vitest, Jest, or Testing Library dependency, and no test script in either package.json. There are no test files.
  • No Prettier configuration exists. Formatting is by convention only.
  • ESLint runs on the frontend only, via the flat config at mygf/eslint.config.js.
  • Frontend type-checking is a separate step (npm run typecheck); the build script runs tsc -b before vite build, so type errors do fail the build.

4. User Roles & Permissions

There are six roles in the database schema, but only four functional personas in the user interface.

The canonical enum is declared identically in the backend at backend/src/models/User.js and the frontend at mygf/src/auth/store.ts:

superadmin | admin | teacher | student | orgadmin | orguser

The roles

Role Description How provisioned Lands on
superadmin Platform owner. Sees across every organization; the only role that can create organizations, issue invitations, run reconciliation, and execute payouts. Seeded via scripts/seedSuperadmin.js /superadmin
admin Organization (center) administrator. Scoped to a single orgId. Manages that center's courses, users, payments, and reviews. Created by a superadmin through /api/sa/users /admin
teacher Instructor within an organization. Manages assigned courses, authors notes, views their students and payments. Created by an admin or superadmin through /api/ad/users /teacher
student A learner with no organization. The default role for public self-signup. Self-registration at /signup /dashboard
orguser A learner who belongs to an organization. Functionally identical to student. Auto-converted from student on org attachment /dashboard
orgadmin Defined in the enum and accepted by a handful of backend route allowlists, but has no dedicated frontend route, layout, or menu. Falls through to the student dashboard. Invitation enum only /dashboard

The student → orguser conversion rule

This is the single most important thing to understand about roles here, and it surprises people.

student and orguser are the same learner persona. They differ only by organization membership. Whenever a user with role student is given an orgId, the system rewrites their stored role to orguser. The conversion lives in normalizeRole() in backend/src/controllers/adUsersController.js, and again during invitation acceptance in backend/src/controllers/authController.js:

const roleFinal = (inv.role === "student" && inv.orgId) ? "orguser" : inv.role;

The admin UI maps orguser back to the label "student" for display (mygf/src/admin/api/adUsers.ts), so an operator never sees the term orguser. Route guards handle both by matching the regex /^org/i — which is also why orgadmin silently lands on the student dashboard.

Frontend route guards

All routing is a single <Routes> block in mygf/src/App.tsx. There is no separate router file.

The primary guard is Shell (mygf/src/shell.tsx). It takes allowedRoles: (string | RegExp)[] and an optional requireMfaIf predicate. It hydrates the session (revalidating if the last check is older than five minutes), holds a loading state until status === "ready", redirects unauthenticated visitors to /login, redirects role mismatches to /login, and diverts to /mfa when the MFA gate is unmet.

A second guard, RequireOrgUser (mygf/src/auth/RequireOrgUser.tsx), protects /course/:courseId with a case-insensitive role match.

Permission matrix

Area Route prefix Frontend guard Backend enforcement
Public catalog /tracks, /about none /api/public, /api/student-catalog
Auth screens /login, /signup, /forgot-password, /reset-password, /accept-invitation, /mfa, /claim-receipt none rate-limited auth routes
Course detail /course/:courseId RequireOrgUser — all six roles requireAuth
Learner dashboard /dashboard, /enrolled Shell(["student", /^org/i]) requireAnyRole('student','orguser','orgadmin')
Teacher console /teacher/* Shell(["teacher"]) /api/teacher, requireRole(['teacher',…])
Center admin console /admin/* Shell(["admin"]) /api/ad/users → requireRole(['admin','superadmin'])
Platform console /superadmin/* Shell(["superadmin"]) /api/sa/*, /api/organizations → requireRole('superadmin')

Backend enforcement is the real boundary. The middleware in backend/src/middleware/authz.js exposes requireAuth, requireRole(required), requireAnyRole(...roles), requireAuthNoRole, and requireRecentAuth (which re-verifies the current password before sensitive changes such as email or password updates).

Representative backend allowlists, taken from backend/src/routes/:

Route file Roles required
organizations.js, saUsers.js, saCourses.js, saPayments.js, saPayouts.js, saReconciliation.js, saAudit.js superadmin only
adUsers.js admin, superadmin
payments.js list & offline entry: admin, teacher — verify/reject/refund/settle: admin only
assessments.js read: superadmin, admin, teacher — create/update: superadmin, admin — delete: superadmin
audit.js, reports.js, certificates.js, notes.js (write), orders.js, students.js superadmin, admin, teacher
courseReviewsRoute.js, studentCatalog.js, studentProgress.js, studentWishlist.js student, orguser, orgadmin
razorpay.js, joinState.js, studentPayments.js, notifications.js any authenticated user

Privileged actions are additionally rate-limited by backend/src/middleware/adminLimits.js: adminActionLimiter at 60 requests/minute, heavyActionLimiter at 10/minute.

Navigation

The sidebar is generated entirely from the MENUS map in mygf/src/config/nav.ts — the single source of truth for per-role navigation, rendered by mygf/src/components/layout/Sidebar.tsx.

Note that MENUS.student, MENUS.orgadmin, and MENUS.orguser are all empty arrays, by design. Learners have no role sidebar; they navigate through the dashboard's own UI.

Beyond roles

There is no per-user ACL, no feature-flag system, and no SaaS plan tier. Access control is role plus organization scope, and nothing more. Specifically:

  • Organization scoping is the second access dimension. Controllers filter by actor.orgId; a superadmin must pass orgId explicitly. Organization carries status: active|inactive and a suspended boolean — tenant-level kill switches, not per-feature toggles.
  • User.status: active|disabled blocks login with a 403.
  • managerId on User establishes an org hierarchy for reporting and assignment. It does not gate permissions.
  • "Subscriptions" are not plan tiers. The Subscription collection records a per-course paid access period (studentId + courseId + orgId, status: paid|refunded|canceled). It has nothing to do with application feature access.

5. Architecture

System diagram

                          ┌───────────────────────────────┐
                          │   Browser — React 19 SPA      │
                          │   (Vercel)                    │
                          │                               │
                          │   Zustand ..... auth + enroll │
                          │   React Query . server state  │
                          └───────┬───────────────▲───────┘
                                  │               │
              HTTPS + HttpOnly cookies      Server-Sent Events
              (sid / sr) + X-CSRF-Token     (live notifications)
                                  │               │
                          ┌───────▼───────────────┴───────┐
                          │   Express 5 REST API          │
                          │   (Render)                    │
                          │                               │
                          │   helmet · cors · rate-limit  │
                          │   CSRF double-submit          │
                          │   requireAuth → requireRole   │
                          │   routes → controllers        │
                          │        → services → models    │
                          │                               │
                          │   scheduler: 3 background jobs│
                          └──┬───────┬────────┬────────┬──┘
                             │       │        │        │
                    ┌────────▼──┐ ┌──▼─────┐ ┌▼──────┐ ┌▼──────┐
                    │ MongoDB   │ │Razorpay│ │Cloud- │ │Resend │
                    │ Atlas     │ │payments│ │inary  │ │ email │
                    │(Mongoose) │ │+webhook│ │media  │ │       │
                    └───────────┘ └────────┘ └───────┘ └───────┘
                                       │
                                  ┌────▼─────┐
                                  │  Slack   │  (payment failure alerts)
                                  │ webhook  │
                                  └──────────┘

Request lifecycle

Every state-changing request passes through the same chain:

  1. CSRF token issuance. The client calls GET /csrf, which sets a double-submit cookie (__Host-csrf in production, csrf in development).
  2. Axios interceptor in mygf/src/api/client.ts attaches the token as an X-CSRF-Token header on every mutating call. Certain endpoints sit on an exempt list.
  3. requireAuth verifies the JWT, read from the sid HttpOnly cookie (or a Bearer header, used in development).
  4. requireRole / requireAnyRole checks the role allowlist and returns 403 on mismatch.
  5. Controller executes, delegating to a service for non-trivial business logic, which talks to Mongoose models.

Authentication architecture

Custom JWT — no Auth0, Firebase, or Clerk.

  • Access token → sid cookie, HttpOnly, TTL ACCESS_TTL (default 1h), signed with JWT_ACCESS_SECRET.
  • Refresh token → sr cookie, HttpOnly, TTL 30 days, signed with JWT_REFRESH_SECRET, and persisted as a RefreshToken document carrying a jti.
  • Rotation with reuse detection. Each refresh revokes the old jti and issues a new one. Presenting an already-revoked jti trips reuse-detected and invalidates the chain.
  • Tokens never appear in a JSON response body. They exist only as HttpOnly cookies. This is a deliberate XSS mitigation — a successful script injection cannot read the session.
  • Silent refresh is single-flighted through mygf/src/api/refreshGate.ts, so a burst of concurrent 401s produces exactly one refresh call.

Client-side session lifecycle, all in mygf/src/App.tsx:

  • A 4-minute background poll (SESSION_POLL_MS) detects server-side revocation while the user sits idle.
  • Cross-tab logout sync — logout() writes auth:logout to localStorage; a storage listener in every other tab picks it up and logs out there too.
  • A termination seal (eca:auth:terminated in localStorage) prevents a page reload from silently rehydrating a session the user explicitly ended.

Multi-tenancy

orgId is the tenant key. It appears on User, Course, Payment, Enrollment, Note, Assessment, Progress, Payout, and Notification. Controllers derive the scope from the authenticated actor (req.user.orgId) and return 403 when an org-scoped role has no org. A superadmin passes ?orgId= explicitly to act within a tenant.

Frontend structure

mygf/src/
├── main.tsx              GoogleOAuthProvider → BrowserRouter → QueryClientProvider → Toaster
├── App.tsx               the entire route table; session poll; cross-tab logout
├── shell.tsx             the role guard
├── api/                  axios modules, one per resource; client.ts holds interceptors
├── auth/                 Zustand auth store, RequireOrgUser, RequireRole
├── config/               env.ts, api.ts, csrf.ts, nav.ts (the MENUS map)
├── hooks/                useAuthHydration, useNotifications (SSE provider)
├── store/                enrollmentStore (Zustand)
├── components/           public + learner UI, grouped by domain
│   ├── about/  course/  dashboard/  enrolled/  home/  layout/
│   ├── join/             the 4-step admissions wizard (steps/, store/, ui/, utils/)
│   ├── notes/            FlipBookViewer, NotesListModal
│   ├── notifications/    NotificationBell, ReminderPopup
│   ├── pages/tracks/     public catalog: search, filters, infinite scroll, wishlist
│   └── screens/          SignIn, SignUp, ForgotPassword, ResetPassword, AcceptInvitation, ReceiptClaim
└── admin/                the entire back-office
    ├── layouts/          SuperadminLayout, AdminLayout, TeacherLayout
    ├── pages/            superadmin/, admin/, teacher/, common/
    ├── features/         assessments, courses, notes, overview, payments, reports, reviews, settings
    ├── api/              real API clients + mock* twins backed by mockdb.ts
    ├── store/            Zustand stores (adUsers, saUsers, saOrganizations)
    ├── types/            domain TypeScript types
    └── utils/            csv.ts, paths.ts

State is split deliberately: Zustand owns auth and enrollment (long-lived, cross-cutting); React Query owns server data (30-second stale time, no refetch on window focus).

Backend structure

backend/
├── server.js             CORS allowlist, helmet+HSTS, CSRF, rate limiters,
│                         ~40 route mounts, SPA static fallback, boot sequence
├── src/
│   ├── config/           mongo.js, env.js, cloudinary.js, platform.js
│   ├── routes/           42 route files → thin, guard-only
│   ├── controllers/      ~40 controllers → request/response handling
│   ├── services/         enrollmentService.js, paymentReconciliationService.js
│   ├── models/           21 Mongoose schemas
│   ├── middleware/       authz.js, adminLimits.js, debug.js
│   ├── jobs/             enrollmentRecoveryJob.js, paymentExpiryJob.js
│   ├── utils/            notify.js (SSE hub), scheduler.js, email.js,
│   │                     cloudinary.js, pdfRenderer.js, safePuppeteer.js, mfaCrypto.js
│   └── certificates/     registry.js, render.js
├── templates/certificates/   classic/, maroon/  (meta.json + style.css + template.hbs)
└── scripts/              seedSuperadmin.js, resetSuperadminTotp.js, reconcilePayments.js,
                          migrate-vendor-to-teacher.js

The boot sequence in server.js is: connectMongo() → app.listen(PORT || 5004) → startScheduler() → verifyMailer(). In production it fails fast if JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, or JWT_MFA_SECRET are missing or identical to one another.

Background jobs

Started by backend/src/utils/scheduler.js:

Job Interval Purpose
Notification tick NOTIFY_TICK_MS, default 60 s Fires due and recurring reminders over SSE
enrollmentRecoveryJob 2 min Retries enrollment for payments flagged needsEnrollment
paymentExpiryJob 15 min Expires abandoned Razorpay orders, verifying against the Razorpay API first

Real-time

Delivery is Server-Sent Events, not WebSockets. The hub in backend/src/utils/notify.js keeps a per-userId client registry with addClient() / emitToUser(). The frontend consumes it through an EventSource in mygf/src/hooks/useNotifications.tsx.


6. Data Model

21 Mongoose collections, all in backend/src/models/.

Two things a reader expects to find here but will not: there is no Order model and no Certificate model. "Orders" in the admin UI are a projection over Payment (see ordersController.js, which imports Payment, Course, User, Organization, and Enrollment). Certificates are stored as a certificateUrl string on the Progress document and rendered on demand.

Identity & tenancy

Collection Key fields
User email, passwordHash (select:false), role (6-value enum), status: active|disabled, orgId → Organization, mfa{ required, method: otp|totp, totpSecretHash, emailOtp }, backupCodes, invitedBy, managerId, isVerified, password-reset and email-change token fields
Organization code (unique), name, domain, contactName/contactEmail, phone, address block, status: active|inactive, suspended, deletedAt
Invitation email, role, mfaRequired, mfaMethod, managerId, orgId, tokenHash, expiresAt (7 days), accepted, invitedBy
RefreshToken userId, jti (unique), exp, device, ip, isRevoked, replacedBy
AuditLog userId, action (enum), ip, ua, meta
StudentFormProfile studentId (unique), fullName, age, gender, birth, address, mobile, email, photoUrl

Learning

Collection Key fields
Course title, slug, description, category, programType, price (paise), visibility: public|private|unlisted, status: draft|published|archived, orgId, ownerId, teacherId, managerId, chapters[], isBundled, discountPercent, level, platformFee (default 4900 = ₹49), courseType: free|paid, demoVideoUrl, tags[], ratingAvg, ratingCount, centerTeacherAssignments[]
Enrollment studentId, courseId, orgId, status: free|premium|trial|revoked, enrollmentType: online|offline|admin|teacher, paymentId, managerId — unique on {studentId, courseId}
Progress studentId, courseId, orgId, statuses[{ chapterIndex, status }], overallStatus, certificateUrl
Note orgId, courseId, ownerId, title, kind: rich|pdf, html (sanitized), pdfUrl, pdfPublicId, status: draft|published|archived
Assessment title, orgId, createdBy, managerId, status: draft|published|archived, questions[{ type: mcq|puzzle|truefalse, text, options, answer, points, explanation }] (max 20), groupId, openAt/closeAt, timeLimitSeconds, maxAttempts, passingScore
AssessmentGroup name, scope: global|org, orgId, isActive, order
CourseAssignment courseId, orgId, assignedBy, isActive

Commerce

Collection Key fields
Payment type: online|offline, method: razorpay|stripe|upi|cash|other, status (7-value state machine, below), createdSource: student_claim|admin_manual|teacher_manual|superadmin_manual|online_gateway, amount (paise), currency, orgId, courseId, studentId, receiptNo, referenceId, notes (JSON blob — see §7), provider* fields, webhookEventId, providerVerified, submittedBy, verifiedBy, verifiedAt, verificationMode: direct_admin_verify|reconciled_auto|manual_review, settled, needsEnrollment, enrollmentRetryCount, lastEnrollmentError
Subscription studentId, courseId, orgId, method: manual|razorpay, providerSubscriptionId, currentPeriodStart/End, renews, status: paid|refunded|canceled, amount, currency
Payout orgId, paymentIds[], totalAmount (paise), status: pending|completed|failed, method: manual|razorpay, reference, createdBy

The Payment.status state machine, with the transitions documented inline in the schema:

online:    pending ──────────────► captured ──► refunded / failed

offline:   pending_verification ──► captured ──► refunded / rejected
                    │
                    └──────────────► reconciled     (duplicate record)

Payment carries several partial unique indexes that structurally prevent duplicate online captures and duplicate receipt numbers.

Engagement

Collection Key fields
Notification userId, orgId, type, title, body, data, priority: low|normal|high, dueAt, recurrence: none|daily|weekly|monthly, maxTimes, sentCount, readAt, resolvedAt
Review courseId, studentId, name, rating (0–5), comment, status: visible|hidden|pending
WishlistItem userId, courseId
ContactMessage name, email, phone, subject, message, status: new|read|resolved, ipAddress, userAgent
Center name, location, region, orgId, status — deprecated in favor of Organization; its route is commented out in server.js

7. Comprehensive Feature Set

7.1 Authentication & Security

Feature Where
Email/password login with bcrypt authController.js, SignIn.tsx
Student self-registration (strong-password policy: ≥8 chars, upper + lower + digit) POST /auth/signup-student
Email pre-check that routes a visitor to sign-in vs. sign-up GET /auth/precheck
Google OAuth @react-oauth/google — wired but currently commented out in SignIn.tsx / SignUp.tsx
MFA — email OTP (6 digits, hashed, 10-min expiry, max 5 attempts) Mfa.tsx
MFA — TOTP (speakeasy, AES-GCM-encrypted secret, ±30 s window, replay protection, QR provisioning) mfaCrypto.js
Backup codes — 8 single-use, SHA-256 hashed POST /auth/me/2fa/enable
Superadmin cannot disable their own 2FA (hard 403) selfTotpDisable
Invitation-based staff onboarding (hashed token, 7-day expiry, carries role + orgId + MFA policy) AcceptInvitation.tsx
Password reset — hashed token, 1-hour expiry, does not reveal whether the email exists, revokes all refresh tokens on success POST /auth/forgot-password, /auth/reset-password
Suspicious-login detection — new device or IP triggers an audit entry and an email alert detectSuspiciousLogin()
Multi-device session management — list and revoke GET/DELETE /auth/me/sessions, POST /auth/me/sessions/revoke-others
Audit trail AuditLogs.tsx
CSRF double-submit cookie csrf.ts, server.js
Granular rate limiting (login 10/15 min, OTP 20/10 min, signup, forgot-password, invitations) server.js

There is no public path to self-register as admin, teacher, or superadmin. Privileged accounts arrive only by seed script or invitation.

7.2 Public Catalog & Discovery

Route /tracks → TracksAndCollectionsSection.tsx, accessible without authentication.

Supporting components live in mygf/src/components/pages/tracks/: SearchBar.tsx, SidebarSmartFilter.tsx, FilterChips.tsx, plus the useAutoLoadOnIntersect.ts (infinite scroll) and useDebouncedValue.ts hooks, skeleton loaders, and wishlistStore.ts. Video-preview modals and wishlist toggles are inline.

Course detail at /course/:courseId → CourseDetail.tsx: header, progress meter, chapter levels, reviews section, video carousel, and the certificate modal.

7.3 Admissions — the Join flow

JoinNowModal.tsx orchestrates a four-step wizard, each step in mygf/src/components/join/steps/:

  1. CourseStep — confirm the selected course
  2. DetailsStep — full name, age, gender, date of birth, mobile, address, photo upload
  3. PaymentStep — online (Razorpay) or offline (cash), full or partial
  4. SuccessStep — confirmation and a downloadable PDF receipt (jspdf + html2canvas)

Details entered once are persisted to StudentFormProfile and reused on subsequent joins. Partial wizard state survives a page reload through server-side join-state persistence (joinStateController.js).

7.4 Payments & Commerce

This is the deepest and most carefully engineered module in the system.

Two rails. Payment.type is online or offline.

Online goes through Razorpay: the backend creates an order, the client opens the Razorpay checkout, and the callback is verified by HMAC signature. A separate webhook at POST /api/checkout/razorpay/webhook (mounted with raw-body parsing, before the JSON body parser) provides the authoritative confirmation. Webhook idempotency is enforced by storing webhookEventId. Failures post a non-blocking alert to a Slack webhook.

Offline is cash collected at a center. The record lands as pending_verification with a receiptNo and referenceId, and waits for an administrator to click "Verify & Enroll."

Partial payments. A Payment may cover only part of a course fee. The breakdown is serialized as a JSON blob into the Payment.notes string field, carrying mode: full|part, totalPaise / totalAmount, mrpPaise, salePaise, promoPaise, and discountKind. Remaining balance is derived from it. Note the asymmetry, which is a real trap: online totals are recorded in totalPaise (paise), offline totals in totalAmount (rupees). The getPartInfo() helper in the superadmin payments page normalizes both.

Discounts. discountKind is none | coupon | refer (mygf/src/components/join/types.ts). A course additionally carries discountPercent and a platformFee (default ₹49).

Receipts and self-claim. A student who paid cash can claim their payment at /claim-receipt (ReceiptClaim.tsx) by entering the receipt or reference number, producing a createdSource: student_claim record.

The superadmin payments console — Payments.tsx, ~1190 lines, the single densest UI in the app:

  • Filter bar: free-text search (email / order ID / method), status, method (Cash = offline, Razorpay = online), and organization
  • Table columns: When, Order/Sub (ID prefixed ONLINE_ / PAY_ / CASH_ / REF_ depending on which provider or receipt field is populated), Student, Center, Course, Assigned Teachers, Amount, Method, Payment Type (Full vs Part), Status badge, Actions
  • Row actions, gated on status: Verify & Enroll (pending_verification), Refund (captured), Settle (offline + captured + part-mode + remaining > 0), View
  • A payment-details modal reconstructing the full breakdown — Course MRP → after course discount → promo discount → total payable → payment mode → paid now → remaining balance — alongside the join-form details captured at admission
  • An Assign Teachers modal (AssignTeachersModal.tsx) for editing per-center teacher assignments on a course
  • Record Offline entry via OfflinePaymentModal.tsx

"Settle" clears a remaining part-payment balance as a record-only operation — it does not alter enrollment, because the student already has access.

Orders & invoices. Orders.tsx and OrderInvoice.tsx present payments as orders with printable invoices.

7.5 Reconciliation

paymentReconciliationService.js solves a problem specific to cash: the same real-world payment can be entered twice — once when the student self-claims it, once when an administrator records it manually.

The service auto-matches duplicates scoped to the same org, course, student, and amount, keying first on exact referenceId and falling back to receiptNo. It selects the oldest / highest-priority record as authoritative, promotes it to captured, marks the duplicate reconciled, merges the join-form metadata from both notes blobs, and triggers enrollment. Surfaced at Reconciliation.tsx; also runnable offline via scripts/reconcilePayments.js.

7.6 Payouts

A superadmin settles captured payments to the organization that earned them. A Payout records the paymentIds[] it covers, a totalAmount in paise, and a status; the included payments are flipped to settled: true. UI at Payouts.tsx with a per-payout drill-down at PayoutDetail.tsx.

7.7 Enrollment

ensureEnrollment() in enrollmentService.js is an idempotent upsert aligned to the {studentId, courseId} unique index, tolerating MongoDB duplicate-key error 11000. If it fails after a payment has already been captured, the payment is flagged needsEnrollment: true with an incrementing enrollmentRetryCount, and enrollmentRecoveryJob.js retries every two minutes until it succeeds. A student who has paid always ends up enrolled.

7.8 Courses & Curriculum

Courses carry chapters (each with video / YouTube URL / cover image / assignments), paise pricing with MRP and discount percentage, courseType: free|paid, bundling, tags, and aggregate ratings.

Because a course can run at several centers, teacher assignment is per-center: Course.centerTeacherAssignments[] maps each organization to its own list of teachers, edited through the Assign Teachers modal. Bulk course import from a spreadsheet is available via BulkUploadModal.tsx (xlsx).

7.9 Study Notes & Flipbook

Note.kind is either rich (sanitized HTML, via sanitize-html) or pdf (uploaded to Cloudinary).

PDF notes are stored as authenticated RAW Cloudinary resources and served through signed, expiring URLs (CLOUDINARY_NOTES_ACCESS, default authenticated), so a leaked URL does not grant permanent access to paid material.

The reader is FlipBookViewer.tsx, combining react-pageflip with pdfjs-dist to render a PDF as a page-turning book. It is lazy-loaded and classifies load failures in detail. Also present: NotesListModal.tsx and a plain PdfViewer.tsx. Authoring happens in admin/features/notes/Notes.tsx, available to both admins and teachers ("Add Notes" in the sidebar).

7.10 Assessments & Assignments

Assessment supports mcq, puzzle, and truefalse question types, capped at 20 questions, with openAt / closeAt windows, timeLimitSeconds, maxAttempts, and a passingScore. Assessments are organized into AssessmentGroups scoped either global or org.

Assignments are defined per chapter on the Course schema, with submission review at AssignmentSubmissions.tsx.

See §8 — these pages are routable but hidden from the sidebar.

7.11 Progress, Reports & Certificates

Progress is tracked per chapter and rolled up into overallStatus (studentProgressController.js). Note that status is a free-form string, not an enum — the schema only documents the intended convention (not-started, complete-1, complete-2, completed) and defaults to not-started. Nothing at the database layer enforces it.

Reports (reportsController.js) are enrollment-driven: premium enrollments left-joined against progress, paginated and filterable by organization, student, course, teacher, and status. The UI at admin/features/reports/Reports.tsx offers a template picker and PDF/CSV export.

Certificates are generated server-side: a Handlebars template is rendered to HTML and converted to PDF by Puppeteer. Two themes ship in backend/templates/certificates/ — classic and maroon — each a meta.json + style.css + template.hbs triple. A verification QR code is embedded via qrcode. Rendering runs through pdfRenderer.js and safePuppeteer.js, which prefer a remote Browserless instance (BROWSERLESS_URL) and fall back to a local @sparticuz/chromium binary. The resulting URL is stored on Progress.certificateUrl and surfaced through CertificateModal.tsx and the dashboard's RecentCertificatesCard.tsx.

7.12 Notifications

Typed notifications (certificate_available, course_incomplete, wishlist, new_course) with priorities, due dates, and recurrence (daily / weekly / monthly, bounded by maxTimes).

Delivery is real-time over SSE from the hub in notify.js, consumed by useNotifications.tsx and rendered by NotificationBell.tsx and ReminderPopup.tsx.

There is no chat or direct messaging. The Community page exists but is not exposed (§8). Inbound ContactMessage records from the public contact form are triaged by admins.

7.13 Reviews & Wishlist

Star ratings 0–5 with comments, moderated through status: visible | hidden | pending. Aggregates denormalize onto Course.ratingAvg / ratingCount. Learner-facing in ReviewsSection.tsx; moderation in the admin, superadmin, and teacher Reviews.tsx pages.

Wishlist is a simple { userId, courseId } join, toggled from catalog cards.

7.14 File Uploads

multer receives the upload, uploadsController.js forwards it to Cloudinary. Used for note PDFs, course cover images, and student photographs.

7.15 Cross-cutting UX

  • Splash screen — inline CSS in index.html, removed after React's first paint with a 350 ms fade
  • Exports — CSV via admin/utils/csv.ts (orders, students, users, organizations, submissions, reports); XLSX via xlsx (bulk course upload, organizations); PDF via jspdf + html2canvas (reports, receipts, certificates)
  • Search, filters, pagination, infinite scroll — throughout the catalog and every admin table
  • Responsive design — Tailwind utilities, with mobile sidebar and topbar in components/layout/
  • Animations — Framer Motion, plus SuccessAnimation.tsx and TopProgressBar.tsx
  • Dark mode — partial only. dark: variants appear in StudentSidebar.tsx, but there is no global theme toggle.
  • Internationalization — none. No i18next or react-intl; the application is English-only.
  • PWA / offline — none. No service worker, no manifest.
  • Drag and drop — none; multi-select dropdowns are used instead.

8. Built but Not Currently Exposed

A meaningful amount of functionality exists, compiles, and is reachable — but is deliberately hidden. The distinction matters, so it is drawn precisely below.

Routed, but hidden from the sidebar

These pages are registered in mygf/src/App.tsx and can be reached by typing the URL. They are only commented out of the MENUS map in mygf/src/config/nav.ts, so no link points at them.

Console Hidden pages
Superadmin students, subscriptions, assessments, cms, compliance, integrations
Admin curriculum, assessments, assignments, certificates, subscriptions, media, marketing, community, students
Teacher assessments, settings

Not routed at all

These files exist under mygf/src/admin/pages/ but are never imported into App.tsx:

  • superadmin/Analytics.tsx — imported but the import and its <Route> are commented out
  • superadmin/Catalog.tsx, superadmin/Monetization.tsx, superadmin/Reviews.tsx
  • teacher/Reviews.tsx

Other dormant machinery

  • Mock data layer. Nearly every client in mygf/src/admin/api/ has a mock*.ts twin backed by mockdb.ts, selected when VITE_USE_MOCK is set or VITE_API_URL is absent. This lets the entire back-office run with no server.
  • Static HTML landing pages. The setup described in HTML_PAGES_SEPARATION.md is deprecated — commented out in both vite.config.ts and server.js. / now redirects to /login, and components/home/HomeLanding.tsx is disconnected.
  • /api/centers — the centers router is commented out in server.js; Organization superseded it.
  • Google OAuth — fully wired on both ends, but the button is commented out in SignIn.tsx and SignUp.tsx.
  • Alternate email providers — nodemailer and mailgun.js are installed and importable; resend is the active provider.

9. Configuration & Deployment

Frontend environment variables

Variable Purpose
VITE_API_URL Base URL of the backend API. If absent, the mock layer activates.
VITE_GOOGLE_CLIENT_ID Google OAuth client ID
VITE_USE_MOCK Forces the mock data layer on
VITE_DEV_HTTPS Set to 1 to serve the dev server over HTTPS using mygf/certs/

Backend environment variables

Group Variables
Database MONGODB_URI, MONGO_URL
Server PORT (default 5004), NODE_ENV, CORS_ORIGIN, CROSS_SITE, PUBLIC_APP_URL, PUBLIC_API_URL
JWT & MFA JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, JWT_MFA_SECRET, ACCESS_TTL, REFRESH_TTL, REFRESH_TTL_SEC, MFA_ENC_KEY, TOTP_ISSUER, SESSION_SECRET
Razorpay RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET, RAZORPAY_WEBHOOK_SECRET
Cloudinary CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET, CLOUDINARY_FOLDER, CLOUDINARY_NOTES_ACCESS
Email RESEND_API_KEY, EMAIL_FROM, MAIL_FROM, MAIL_FROM_NAME
PDF rendering BROWSERLESS_URL, BROWSERLESS_TOKEN, ALLOW_PDF_LOCAL_FALLBACK, PUPPETEER_CORE, PDF_RENDER_TIMEOUT_MS, PDF_ASSET_ALLOWLIST
Misc SLACK_WEBHOOK_URL, ADMIN_EMAILS, CONTACT_ADMIN_EMAILS, SUPERADMIN_EMAIL, DEFAULT_PLATFORM_FEE_PAISE, NOTIFY_TICK_MS
Debug toggles DEBUG_AUTH, DEBUG_MFA, DEBUG_CORS, DEBUG_STUDENT, DEBUG_CATALOG, DEBUG_NOTIFICATIONS, NOTE_BUG

In production the server refuses to start if JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, and JWT_MFA_SECRET are not all present and mutually distinct.

Running locally

# Backend — http://localhost:5004
cd backend
npm install
npm run dev          # nodemon ./server.js

# Frontend — http://localhost:5173
cd mygf
npm install
npm run dev          # vite, strictPort

The Vite dev server proxies /api and /csrf to http://localhost:5004, rewriting the cookie domain and patching SameSite=None so cross-origin auth cookies survive local development (mygf/vite.config.ts).

Other frontend scripts: npm run build (tsc -b then vite build), npm run typecheck, npm run lint, npm run preview.

To seed the first platform account: node backend/scripts/seedSuperadmin.js.

Deployment

  • Frontend → Vercel. mygf/vercel.json rewrites all routes to / for SPA history fallback.
  • Backend → Render. There is no render.yaml, Dockerfile, or Procfile in the repository; the service is configured through the Render dashboard. The code detects the platform via RENDER, RENDER_INTERNAL_HOSTNAME, and RENDER_SERVICE_ID, and sets trust proxy accordingly.
  • CI. None. .github/ contains only a CODEOWNERS file.
  • server.js can additionally serve the built SPA from mygf/dist with history fallback, allowing single-service deployment.

Existing operational guides

The repository root already documents specific operational problems; this file does not duplicate them. See PRODUCTION_SETUP.md, CORS_FIX_SUMMARY.md, RESEND_SETUP_GUIDE.md, RESEND_403_ERROR_FIX.md, SMTP_FIX_GUIDE.md, EMAIL_DELIVERY_TROUBLESHOOTING.md, DOMAIN_VERIFICATION_GUIDE.md, INVITATION_LINK_FEATURE.md, DEV_MODE_TESTING_GUIDE.md, and doc/settings-feature-audit.md.


10. Known Inconsistencies

Recorded for accuracy, not as a task list. A reader working through the codebase will hit these and should not assume they have misread something.

The role enum is duplicated in five places with different member sets.

Location Members
backend/src/models/User.js all six — canonical
mygf/src/auth/store.ts, mygf/src/config/nav.ts all six
backend/src/models/Invitation.js five — omits superadmin
mygf/src/admin/types/user.ts four — omits both org roles
mygf/src/admin/api/adUsers.ts three — teacher, orguser, student
mygf/src/admin/utils/paths.ts two — superadmin, admin

Other observations:

  • orgadmin is a first-class enum member with no UI surface. It resolves to the student dashboard through the /^org/i regex in Shell.
  • Two parallel RequireRole implementations exist — mygf/src/auth/RequireRole.tsx (flat allowlist) and mygf/src/admin/auth/RequireRole.tsx (hierarchical, superadmin ≥ admin ≥ orgadmin). Neither is mounted in App.tsx; Shell and RequireOrgUser do the real work.
  • routeForRole() is duplicated across four files: SignIn.tsx, SignUp.tsx, Mfa.tsx, and admin/auth/RequireRole.tsx.
  • Two near-identical route files exist: saReconciliation.js and saReconcilliation.js (note the spelling).
  • The part-payment total is stored in paise for online payments (totalPaise) but in rupees for offline ones (totalAmount), inside the same notes JSON blob.
  • Build archives are committed to the repository: mygf.zip (~327 MB), mygf/eca.zip, and backend/back.zip. These are not source.
  • mygf/README.md is the unmodified Vite scaffold template and describes nothing about this project.

About

Production-ready multi-tenant education platform built with React, TypeScript, Node.js, Express and MongoDB. Supports organization-scoped access, multi-role dashboards, course lifecycle, enrollments, progress, Razorpay/offline payments, MFA-secured auth, SSE notifications, certificates, notes, reviews and audit-aware workflows.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages