03 — User Journeys (Academics Module)
- 1. Create academic year + rollover
- 2. Create grade / section / class ladder
- 3. Assign subjects + teachers (assignment matrix)
- 4. Manage conflicts
- 5. Student-facing structure browse (read-only)
- 6. Cross-cutting exit / failure rules
End-to-end journeys computed from
src/modules/academics/controllers/*,services/*, and the consumer modules (timetable, attendance). Mirror of the admin flow indocs/user-flows/END_TO_END_USER_FLOWS.md:111-138. Every step maps to an implemented endpoint;(planned)marks absent server features. Error codes per 00-shared/07 §3; conflict copy matches service strings exactly.
1. Create academic year + rollover
entry: Academics → Academic Years (admin); fresh tenant: empty state
intent: establish the session calendar and promote the current year
sequenceDiagram
actor A as Org Admin
participant Y as AcademicYearsScreen
participant API as POST /academic-years
A->>Y: "Add year" → form (name, startDate, endDate)
Y->>API: {name:"2026-2027", startDate:"2026-04-01", endDate:"2027-03-31"}
alt 200 created (status defaults upcoming)
Y-->>A: new row, badge "Upcoming" (academic-year.service.ts:30-33)
else 409 DUPLICATE_RESOURCE
Y-->>A: inline "Academic year "2026-2027" already exists." (academic-year.service.ts:27-28)
else 400 VALIDATION_ERROR
Y-->>A: field errors (bad date string / missing name)
end
A->>Y: row → "Set as current"
Y->>API: PATCH /academic-years/:id/set-current
API-->>Y: 200 doc {isCurrent:true, status:"active"}
Note over API: all other years unset isCurrent (academic-year.service.ts:72-77)
Y-->>A: badge "Current" on target; former current loses badge
- Decision points: duplicate year (409) → jump to the existing row instead of forcing a rename; rollover is a destructive-ish global state flip — confirm dialog ("Old current year will stop being current") since it affects every downstream module's year filter.
- Failure covers: 401 (token expired → refresh → re-login), 429
RATE_LIMITED(countdown, no auto-retry), offline (blocked, cached list shown). - Abandonment: back cancels form; no partial writes (create is atomic, service
checks name first then
repo.create).
2. Create grade / section / class ladder
entry: Academics → Grades / Classes (admin or coordinator)
intent: build the tenant's ladder, then the concrete classes
flowchart TD
A[Open Grades] --> B{Grades exist?}
B -- No --> C[POST /grades name=Grade 1, displayOrder=1]
B -- Yes --> D[Next grade or skip]
C --> E[repeat for Grade 2..12 with displayOrder++]
D --> F{Create sections?}
F -- Yes --> G[POST /sections gradeId, name=A/B/C]
F -- No --> H[POST /classes academicYearId, gradeId, sectionId, name]
G --> H
H --> I[Class list shows grade+section chips]
I --> J[Class detail: capacity, classTeacherId, roomId]
- Grade create: 409 if name taken (
grade.service.ts:21-24) — client pre-checks the loaded list, but still renders server 409 inline (race-safe). - Section create: no duplicate guard in code (
section.service.ts:16-18) — client warns when(gradeId, name)repeats (OQ-4). - Class create: requires
academicYearId+gradeId+sectionId(allIsMongoId,create-class.dto.ts:5-20) — cascading pickers: year → grade → section (13_State_Management.md §2). Class name example "Grade 10 - A" (create-class.dto.ts:22); no uniqueness enforced (class.service.ts:16-18). - Ordering: grades default sorted by
displayOrder(grade.service.ts:39); classes/sections come back in insertion order (class.service.ts:44-48,section.service.ts:41-48) — client preserves server order, adds no re-sort.
3. Assign subjects + teachers (assignment matrix)
entry: Class detail → "Subjects & teachers" (coordinator)
intent: bind teacher×subject×class×year
sequenceDiagram
actor C as Coordinator
participant D as ClassDetail
participant A as AssignmentsRepo
participant API as POST /subject-assignments
C->>D: open class (year-scoped)
D->>API: GET /subject-assignments/by-class/:classId?academicYearId=
API-->>D: existing roster []
C->>D: "Add subject" → pick subject + teacher
D->>API: {teacherId, subjectId, classId, academicYearId}
alt 200
D-->>C: row appears (SubjectAssignmentRow)
else 400 VALIDATION_ERROR
D-->>C: invalid ObjectId highlighted
else 409/422 (planned: duplicate/conflict check)
D-->>C: conflict banner — server does not check today (OQ-4)
end
C->>D: remove row → DELETE /subject-assignments/:id
D-->>C: "Assignment not found." if stale (subject-assignment.service.ts:34)
- Duplicate assignments are possible server-side (no unique index,
subject-assignment.schema.ts:22-25; no service check,subject-assignment.service.ts:13-17) — client disables an already-added (subject, teacher) pair in the picker to prevent double rows; treat as guard, not guarantee (OQ-4). - No update endpoint — correcting teacher = delete + re-create
(
subject-assignment.controller.ts:19-39); UI offers "Replace" = delete + create.
4. Manage conflicts
entry: Class detail or Teacher roster, conflict detected by client
flowchart TD
A[Client renders roster] --> B{Conflicts?}
B -- Duplicate assignment (same subject twice) --> C[Warning banner + merge action]
B -- Teacher double-booked across classes --> D[Warning: teacher teaches same subject in 2 classes]
B -- Class without subjects --> E[Empty-state callout 'Add subjects']
B -- Section/class orphaned (grade deleted) --> F[Orphan callout + 'reparent or archive']
C --> G[Delete duplicate row]
D --> H[Open teacher roster to review]
E --> I[Jump to assignment matrix]
F --> J[Delete orphan (soft) or move]
- All conflict signals are client-computed (server exposes raw lists only).
(planned)server-side: validation on assignment create, cascade/orphan cleanup jobs (RELATIONSHIPS.md:134-138), per OQ-3/OQ-4.
5. Student-facing structure browse (read-only)
entry: Home → Academics (student/parent, read)
intent: see own grade/section/class + subjects for the current year
sequenceDiagram
actor S as Student
participant H as AcademicsBrowse
participant R as ReferenceRepo
participant API as GET /academic-years | /grades | /classes | /subjects
S->>H: open Academics
H->>API: current year (isCurrent=true resolved client-side from list)
API-->>H: year {status:"active", isCurrent:true}
H->>API: classes of year (GET /classes/by-year/:id)
H-->>S: own class resolved via enrollment (client filter) — badge "Your class"
H->>API: subjects (GET /subjects) + roster (by-class/:classId)
H-->>S: tree: Grade 10 → Section A → Class "Grade 10 - A" → subjects w/ teachers
- Server has no "my data" scoping for structure (OQ-1 in
01); the client resolves the student'sclassIdfrom enrollment (Students module) and filters client-side. Subject marks fields (max/passing) are not shown to students (client policy — server returns them in the same doc,subject.schema.ts:21-32). - Offline: structure cached 24 h (
13_State_Management.md §3) → read works offline.
6. Cross-cutting exit / failure rules
- Back = pop to list (state preserved via
StatefulShellBranch,00-shared/05 §3); abandonment at any form step = no server side-effects. - Permission denial (when OQ-1 lands): 403
PERMISSION_DENIED→ hidden CTAs + guarded routes (00-shared/05 §1). - Session expiry mid-flow: silent refresh; failure → re-login, in-progress form preserved in memory.
- Offline: all writes blocked with
AppOfflineBanner; cached reads allowed.