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.
- Overview
- Main Purpose
- Technology Stack
- User Roles & Permissions
- Architecture
- Data Model
- Comprehensive Feature Set
- Built but Not Currently Exposed
- Configuration & Deployment
- Known Inconsistencies
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
localStoragekeys (eca:auth:terminated), in the archivedmygf/eca.zip, and in some comments. It refers to this same product. There is no separate "ECA" application.
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.
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 |
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.
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.
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.
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.
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.
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.
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
testscript in eitherpackage.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); thebuildscript runstsc -bbeforevite build, so type errors do fail the build.
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
| 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 |
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.
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.
| 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.
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.
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 passorgIdexplicitly.Organizationcarriesstatus: active|inactiveand asuspendedboolean — tenant-level kill switches, not per-feature toggles. User.status: active|disabledblocks login with a 403.managerIdonUserestablishes an org hierarchy for reporting and assignment. It does not gate permissions.- "Subscriptions" are not plan tiers. The
Subscriptioncollection records a per-course paid access period (studentId+courseId+orgId,status: paid|refunded|canceled). It has nothing to do with application feature access.
┌───────────────────────────────┐
│ 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 │
└──────────┘
Every state-changing request passes through the same chain:
- CSRF token issuance. The client calls
GET /csrf, which sets a double-submit cookie (__Host-csrfin production,csrfin development). - Axios interceptor in mygf/src/api/client.ts attaches the token as an
X-CSRF-Tokenheader on every mutating call. Certain endpoints sit on an exempt list. requireAuthverifies the JWT, read from thesidHttpOnly cookie (or aBearerheader, used in development).requireRole/requireAnyRolechecks the role allowlist and returns 403 on mismatch.- Controller executes, delegating to a service for non-trivial business logic, which talks to Mongoose models.
Custom JWT — no Auth0, Firebase, or Clerk.
- Access token →
sidcookie, HttpOnly, TTLACCESS_TTL(default1h), signed withJWT_ACCESS_SECRET. - Refresh token →
srcookie, HttpOnly, TTL 30 days, signed withJWT_REFRESH_SECRET, and persisted as aRefreshTokendocument carrying ajti. - Rotation with reuse detection. Each refresh revokes the old
jtiand issues a new one. Presenting an already-revokedjtitripsreuse-detectedand 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()writesauth:logouttolocalStorage; astoragelistener in every other tab picks it up and logs out there too. - A termination seal (
eca:auth:terminatedinlocalStorage) prevents a page reload from silently rehydrating a session the user explicitly ended.
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.
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/
├── 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.
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 |
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.
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.
| 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 |
| 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 |
| 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.
| 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 |
| 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.
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.
JoinNowModal.tsx orchestrates a four-step wizard, each step in mygf/src/components/join/steps/:
- CourseStep — confirm the selected course
- DetailsStep — full name, age, gender, date of birth, mobile, address, photo upload
- PaymentStep — online (Razorpay) or offline (cash), full or partial
- 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).
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.
multer receives the upload, uploadsController.js forwards it to Cloudinary. Used for note PDFs, course cover images, and student photographs.
- 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 viajspdf+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 inStudentSidebar.tsx, but there is no global theme toggle. - Internationalization — none. No
i18nextorreact-intl; the application is English-only. - PWA / offline — none. No service worker, no manifest.
- Drag and drop — none; multi-select dropdowns are used instead.
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 |
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 outsuperadmin/Catalog.tsx,superadmin/Monetization.tsx,superadmin/Reviews.tsxteacher/Reviews.tsx
- Mock data layer. Nearly every client in mygf/src/admin/api/ has a
mock*.tstwin backed bymockdb.ts, selected whenVITE_USE_MOCKis set orVITE_API_URLis 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.tsandserver.js./now redirects to/login, andcomponents/home/HomeLanding.tsxis disconnected. /api/centers— thecentersrouter is commented out inserver.js;Organizationsuperseded it.- Google OAuth — fully wired on both ends, but the button is commented out in
SignIn.tsxandSignUp.tsx. - Alternate email providers —
nodemailerandmailgun.jsare installed and importable;resendis the active provider.
| 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/ |
| 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 |
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.
# 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, strictPortThe 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.
- Frontend → Vercel. mygf/vercel.json rewrites all routes to
/for SPA history fallback. - Backend → Render. There is no
render.yaml,Dockerfile, orProcfilein the repository; the service is configured through the Render dashboard. The code detects the platform viaRENDER,RENDER_INTERNAL_HOSTNAME, andRENDER_SERVICE_ID, and setstrust proxyaccordingly. - CI. None. .github/ contains only a
CODEOWNERSfile. server.jscan additionally serve the built SPA frommygf/distwith history fallback, allowing single-service deployment.
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.
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:
orgadminis a first-class enum member with no UI surface. It resolves to the student dashboard through the/^org/iregex inShell.- Two parallel
RequireRoleimplementations exist — mygf/src/auth/RequireRole.tsx (flat allowlist) and mygf/src/admin/auth/RequireRole.tsx (hierarchical, superadmin ≥ admin ≥ orgadmin). Neither is mounted inApp.tsx;ShellandRequireOrgUserdo 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.jsandsaReconcilliation.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 samenotesJSON blob. - Build archives are committed to the repository:
mygf.zip(~327 MB),mygf/eca.zip, andbackend/back.zip. These are not source. - mygf/README.md is the unmodified Vite scaffold template and describes nothing about this project.