Institutional Sync
BullMQ-based pipeline that synchronizes categories, courses, and enrollments from Moodle.
The system synchronizes institutional data (categories, courses, enrollments) from Moodle LMS via a unified BullMQ-based pipeline.
Trigger Points
| Trigger | Mechanism | Behavior |
|---|---|---|
| Startup | MoodleStartupService (blocking) | Categories always sync; courses + enrollments gated by SYNC_ON_STARTUP |
| Scheduled cron | MoodleSyncScheduler → BullMQ job | Full sync (categories → courses → enrollments) |
| Manual | POST /moodle/sync (superadmin) | Same as cron; returns { jobId } or 409 if already running |
Schedule Resolution
The scheduler uses dynamic cron via SchedulerRegistry (no static @Cron() decorator). The interval is resolved at startup in priority order:
- Database —
SystemConfigkeyMOODLE_SYNC_INTERVAL_MINUTES(admin override viaPUT /moodle/sync/schedule) - Environment variable —
MOODLE_SYNC_INTERVAL_MINUTES - Per-environment default — dev/test: 60 min, staging: 360 min, production: 180 min
Minimum interval is 30 minutes (enforced at all three layers). The interval is converted to a cron expression internally via minutesToCron().
Pipeline Flow
Phase 1: Category Sync
Fetches all Moodle categories and rebuilds the normalized hierarchy (Campus → Semester → Department → Program). Parent entities are cached in in-memory Maps to eliminate N+1 findOneOrFail queries.
Can be skipped at startup via DISABLE_SYNC_CATEGORY_ON_STARTUP=true for faster dev restarts.
Phase 2: Course Sync
Syncs courses for all programs concurrently using pLimit(MOODLE_SYNC_CONCURRENCY). Each program's courses are synced in an independent transaction. Failed programs don't abort others.
Phase 3: Enrollment & Section Sync
Uses a 3-phase architecture to avoid deadlocks from overlapping user rows:
- Concurrent HTTP fetch —
pLimit-gated parallel calls to Moodle per course (thecore_enrol_get_enrolled_usersresponse includes agroupsarray per user) - Batch user upsert — Deduplicated
upsertManyin a single operation (with individual fallback) - Sequential per-course enrollment upsert — For each course:
- Extracts unique groups from the enrolled users'
groupsdata and upsertsSectionentities - Upserts enrollments with the resolved
sectionFK (first group the student belongs to) - Soft-deactivates missing enrollments
- Extracts unique groups from the enrolled users'
No additional Moodle API calls are needed for sections — group data is already returned by the enrolled users endpoint.
Phase 4: User Scope Backfill
backfillUserScopes() derives each synced user's user.program and user.department from enrollment majority (most enrollments wins; ties broken by alphabetical moodleCategoryId so the result is identical across dev/staging/prod for the same Moodle state). It calls the shared pure helper deriveUserScopes() from scope-derivation.helper.ts so the cron path and the login path (MoodleUserHydrationService) cannot diverge.
Atomic source rule: If EITHER user.departmentSource = 'manual' OR user.programSource = 'manual', the user is skipped entirely. The two fields update together or not at all — preventing a user.department ≠ user.program.department inconsistency. An equality guard skips no-op writes so updatedAt is not bumped on idempotent runs.
Campus backfill (fill-if-null only): for users with user.campus IS NULL, the phase parses the username prefix (<campus_code>-<id>, e.g. ucmn-262141935), looks up Campus.code = 'UCMN', and assigns it. This mirrors UserRepository.UpsertFromMoodle's login-time behavior so cron-discovered users get a campus before they ever log in. Existing campus values are never overwritten — there is no campus_source column, so the only safe rule is "only fill if empty." Manual reassignments are preserved; an admin who clears a campus would see it re-derived on the next sync.
The phase logs an aggregate line: Scope backfill: X derived, Y manual skipped, Z no enrollments, W campus assigned.
Phase 5: User Role Derivation
After enrollments land, deriveUserRoles() batch-loads each synced user's active Enrollment rows and UserInstitutionalRole rows in parallel, groups them per user, and calls User.updateRolesFromEnrollments() to recompute the roles array.
updateRolesFromEnrollments() snapshots existing SUPER_ADMIN and ADMIN roles and merges them back after recomputing. Those two roles are manually granted outside Moodle — the snapshot prevents a sync from ever revoking a role it never granted. The phase is non-fatal (try/catch); role drift is preferred over a broken sync run.
Source Tracking
user.departmentSource and user.programSource ('auto' | 'manual') follow the same pattern as UserInstitutionalRole.source. New users default to 'auto' and are eligible for sync-driven derivation. Manual assignments (admin UI, FAC-127) flip the source to 'manual', after which the bulk sync and the login-path hydration both skip the user atomically. Reverting to 'auto' re-enables derivation on the next sync.
Observability — SyncLog
Every sync execution (scheduled, manual, startup) creates a SyncLog record in the database. The processor creates it at job start (status: running) and updates it after each phase and on completion.
Each phase stores a SyncPhaseResult (JSONB) with:
| Field | Description |
|---|---|
status | success, failed, or skipped |
durationMs | Wall-clock time for the phase |
fetched | Remote records received from Moodle |
inserted | New records created (count-before/after strategy) |
updated | Existing records updated via upsert |
deactivated | Records soft-deactivated (missing from remote) |
errors | Per-item error count within the phase |
Overall sync status: completed (all phases succeeded), partial (some failed/skipped), or failed (all failed).
Manual syncs record triggeredBy (FK to User) via CLS (CurrentUserService).
The SyncLog entity does not extend CustomBaseEntity — audit records are never soft-deleted. Queries must use filters: { softDelete: false } to bypass the global MikroORM filter.
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /moodle/sync | SUPER_ADMIN | Trigger sync (409 if already running) |
| GET | /moodle/sync/status | SUPER_ADMIN | Queue state: idle, active, or queued |
| GET | /moodle/sync/history | SUPER_ADMIN | Paginated sync log history with per-phase metrics |
| GET | /moodle/sync/schedule | SUPER_ADMIN | Current interval (minutes), resolved cron, next execution |
| PUT | /moodle/sync/schedule | SUPER_ADMIN | Update interval in minutes (min 30), persists to DB |
Deduplication
- Cron: Fixed
jobId(moodle-sync-scheduled) — BullMQ silently ignores duplicate waiting jobs - Manual: 409 guard checks
activeCount + waitingCount > 0before enqueuing - Processor:
concurrency: 1ensures at most one sync runs at a time
Environment Variables
| Variable | Default | Purpose |
|---|---|---|
SYNC_ON_STARTUP | false | Enable course + enrollment sync at boot |
DISABLE_SYNC_CATEGORY_ON_STARTUP | false | Skip category sync at boot (dev only) |
MOODLE_SYNC_CONCURRENCY | 3 | Max concurrent Moodle HTTP calls (1-20) |
MOODLE_SYNC_INTERVAL_MINUTES | — | Override sync interval (min 30); per-env default used if unset |