Faculytics Docs

Scope Resolution

Role-based data boundaries via ScopeResolverService — Moodle category hierarchy, DEAN/CHAIRPERSON resolution, and cross-semester ID handling.

The ScopeResolverService enforces role-based data boundaries. Scoped endpoints (faculty list, analytics, reports) call it to determine which departments or programs the current user is authorized to access for a given semester.

Moodle Category Hierarchy

Scope resolution depends on the Moodle category tree. Each entity in the domain maps 1:1 to a Moodle category at a specific depth:

Depth 1 — Campus       (e.g., "UCMN")
Depth 2 — Semester      (e.g., "S22526")
Depth 3 — Department    (e.g., "CCS")
Depth 4 — Program       (e.g., "BSCS")

Each MoodleCategory record stores depth, path (e.g., /3/6/8/18), and parentMoodleCategoryId. The domain entities (Campus, Semester, Department, Program) each store a moodleCategoryId matching their corresponding category.

Cross-Semester Category IDs

Each semester in Moodle creates a separate category sub-tree. The same logical department (e.g., "CCS") has a different moodleCategoryId under each semester:

Campus 3 (UCMN)
  ├── Semester 6 (S22526)
  │     └── Department moodleCategoryId=8  (CCS)
  │           └── Program moodleCategoryId=18 (BSCS)
  └── Semester 50 (S12627)
        └── Department moodleCategoryId=60 (CCS)   ← different ID, same dept
              └── Program moodleCategoryId=72 (BSCS)

This means moodleCategoryId is semester-specific and cannot be used to look up entities across semesters. The scope resolver uses code (which equals MoodleCategory.name and is consistent across semesters) instead.

Role Assignment

Institutional roles are stored in UserInstitutionalRole, linking a user + role + MoodleCategory:

RoleExpected DepthSourceAssigned At
DEAN3 (department)manualAdmin endpoint
CHAIRPERSON4 (program)auto or manualMoodle hydration or admin

DEAN Depth Auto-Resolution

The admin endpoint (POST /admin/users/:id/institutional-roles) auto-resolves DEAN assignments:

  • Depth 4 (program): Automatically navigated to parent department (depth 3) via parentMoodleCategoryId
  • Depth 3 (department): Accepted as-is
  • Other depths: Rejected with 400 Bad Request

This prevents misassignment when an admin selects a program-level category for a Dean (a common mistake when promoting a Chairperson to Dean, since the Chairperson's existing role points to a program).

Resolution Logic

ResolveDepartmentIds(semesterId)

Returns string[] | null — the set of department UUIDs the user may access, or null for unrestricted.

ResolveProgramIds(semesterId)

Returns string[] | null — program UUIDs, or null for unrestricted.

RoleBehavior
SUPER_ADMINnull (unrestricted)
DEANnull (sees all programs in their departments)
CHAIRPERSONReturns specific program UUIDs matching their institutional role codes

ResolveProgramCodes(semesterId)

Returns string[] | null — program codes instead of UUIDs, for consumers that filter against snapshotted string columns rather than FK relations.

RoleBehavior
SUPER_ADMINnull (unrestricted)
DEANnull (sees all programs in their departments)
CHAIRPERSONReturns program codes matching their institutional role assignments
OtherForbiddenException

Used by AnalyticsService to narrow queries against mv_faculty_semester_stats.program_code_snapshot. The chairperson branch shares a private resolveChairpersonPrograms(userId, semesterId) helper with ResolveProgramIds — category→program lookup logic is not duplicated.

Consumers

The following services call ScopeResolverService:

ServiceMethodWhat It Scopes
FacultyServiceListFacultyFaculty list filtered by department
AnalyticsServiceResolveDepartmentCodesAnalytics queries filtered by department
ReportsServiceVariousReport generation scoped to department
CurriculumServiceVariousCurriculum data scoped to department/program

How Consumers Use the Result

const departmentIds = await scopeResolver.ResolveDepartmentIds(semesterId);
 
if (departmentIds === null) {
  // Unrestricted — no department filter applied
} else if (departmentIds.length === 0) {
  // No access — query short-circuits to empty result
} else {
  // Filter: WHERE department_id IN (departmentIds)
}

Key Design Decisions

Why Match by Code Instead of moodleCategoryId

Department.code and Program.code are set from MoodleCategory.name during sync (moodle-category-sync.service.ts). They are semantically stable across semesters — "CCS" is always "CCS" regardless of which semester sub-tree it lives under.

By contrast, moodleCategoryId is a Moodle-internal integer that differs per semester. Matching by it would only work for the specific semester the role was originally assigned under.

Why DEAN Supports Depth 4 in the Scope Resolver

Even though the admin endpoint now auto-resolves DEAN to depth 3, existing UserInstitutionalRole records may still reference depth-4 categories (created before the validation was added). The scope resolver handles both depths defensively:

  • Depth 3: Uses moodleCategory.name directly as the department code
  • Depth 4: Fetches the parent MoodleCategory (via parentMoodleCategoryId) and uses its name

Role Priority

When a user holds multiple roles, the highest-priority role wins:

  1. SUPER_ADMIN — unrestricted (null)
  2. DEAN — department-level scope
  3. CHAIRPERSON — program-level scope (narrower)

Pipeline-scope authorization

Analysis-pipeline authorization (create/confirm/cancel/read) reuses ResolveDepartmentIds, ResolveProgramIds, and ResolveCampusIds. Pipeline-specific rules — required axis per role, list default-fill, FACULTY auto-override, 404-before-403 ordering, and the active-scope unique index — live in analysis-pipeline.md §Access Control. PipelineOrchestratorService calls these resolvers before any DB write or flush so a foreign caller cannot cause side effects.

Faculty self-view on analytics endpoints

The faculty report endpoints (/analytics/faculty/:facultyId/report, .../report/comments, .../qualitative-summary, .../questionnaire-types) are the only analytics surfaces that admit the FACULTY role. The AnalyticsController declares its class-level allowlist as (DEAN, CHAIRPERSON, CAMPUS_HEAD, SUPER_ADMIN); each faculty endpoint widens with a per-method @UseJwtGuard(... , FACULTY) and then calls assertFacultySelfScope(currentUser, facultyId).

assertFacultySelfScope (src/modules/analytics/lib/faculty-scope.util.ts) is a no-op for non-FACULTY roles and throws ForbiddenException if a FACULTY user requests a facultyId other than their own. Use the same helper on any future analytics surface that exposes per-faculty data to FACULTY users — equality of user.id === facultyId is the canonical ownership check (faculty users do not have a separate FacultyProfile entity).

For pipeline read access, FACULTY ownership is checked via pipeline.faculty.id === user.id inside PipelineOrchestratorService.assertCanAccessPipeline; verbatim student text in the recommendations response is then redacted by AnalysisAccessService.RedactIfFacultySelfView regardless of which role-coincidence path admitted the request. See Analytics — Faculty Self-View Redaction.

Source Files

FilePurpose
src/modules/common/services/scope-resolver.service.tsCore resolution logic
src/modules/common/services/scope-resolver.service.spec.tsUnit tests
src/modules/admin/services/admin.service.tsDEAN depth validation on assignment
src/entities/user-institutional-role.entity.tsRole ↔ MoodleCategory link
src/entities/moodle-category.entity.tsHierarchy fields (depth, path, parentMoodleCategoryId)
src/modules/moodle/services/moodle-category-sync.service.tsSets Department.code / Program.code from MoodleCategory.name