01 — Product Overview (Shared Platform)
- 1. Purpose
- 2. Business goals
- 3. User goals (top level; per-role detail in each module's 02)
- 4. Stakeholders
- 5. Why this exists
- 6. Platform dependencies (backend)
- 7. Success metrics (platform)
- 8. Global edge cases
- 9. Global assumptions
- 10. Glossary
StudyLyon — Multi-Tenant ERP & School Management Platform. This is the shared foundation document referenced by every module design package.
1. Purpose
StudyLyon is a production-grade, multi-tenant SaaS platform that lets educational institutions (K-12 schools, colleges, coaching centers, training institutes) manage their full operational lifecycle: admissions, academics, attendance, assessments, finance, communication, transport, and library — from a single codebase.
This design-docs collection specifies the client application (Flutter, forward-looking) against the existing NestJS API. The backend is the source of truth for all features, routes, permissions, and data contracts.
2. Business goals
| Goal | Measure |
|---|---|
| Tenant onboarding under 15 minutes | Time from org creation to first login |
| Consolidated operations | Number of workflows moved off paper/spreadsheets |
| Fast daily attendance | Attendance marking latency < 1 s |
| Reliable communication | Notification delivery P95 < 30 s |
| Enterprise security | No cross-tenant data leakage; full audit trail |
| Platform availability | 99.9% API uptime |
3. User goals (top level; per-role detail in each module's 02)
- Super Admin (platform): onboard institutions, monitor platform health, never touch tenant data.
- Org Admin: run the institution from one dashboard; configure everything; invite staff.
- Teacher: mark attendance, set homework, enter marks with minimal taps; mobile-friendly.
- Accountant: invoices, payments, dues, financial reports; no academic write access.
- Staff/Receptionist: manage profiles, support admin workflows, read across modules.
- Parent: track child's attendance, results, fees, and alerts proactively.
- Student: clear, simple views of homework, results, attendance, fees.
- Ops roles (Transport Manager, Librarian, HR, Principal): run their domain module.
4. Stakeholders
Platform operator (SaaS vendor), institution administrators, teaching staff, non-teaching staff, parents/guardians, students, system actors (notification engine, BullMQ workers, audit logger, webhook consumer), and the engineering/design/QA teams consuming these docs.
5. Why this exists
Institutions run fragmented tooling (paper registers, manual ledgers, physical notices, ad-hoc WhatsApp groups). StudyLyon consolidates these into one tenant-isolated platform with real-time visibility for every stakeholder. The client docs exist because the Phase 3 roadmap calls for a mobile companion; these documents are written to the full API surface so the client can grow to full parity.
6. Platform dependencies (backend)
- Node 24, NestJS 11, strict TypeScript
- MongoDB + Mongoose (shared DB, per-tenant isolation via
tenantId) - Redis: cache (
sl:{tenantId}:{key}), rate limiting, BullMQ broker - BullMQ queues:
emails,push,whatsapp,in-app,attendance-process,biometric-sync,invoice-generate,payment-reminder,report-generate,audit-write,tenant-purge,cache-rebuild,webhook-deliver,admission-reminder,admission-expiry - Storage:
STORAGE_PROVIDERinterface — Appwrite (Phase 1), Cloudflare R2 (Phase 2), local dev - WebSockets:
WsModulegateway for realtime delivery - Email (SMTP), push (FCM), WhatsApp (planned)
7. Success metrics (platform)
- Tenant onboarding < 15 min
- Attendance marking latency < 1 s
- Notification delivery P95 < 30 s
- API P95 latency < 200 ms
- 99.9% uptime; zero-downtime deploys
- 100% RBAC enforcement; audit trail on every CUD action
8. Global edge cases
- Tenant isolation violations (cross-tenant ID access) → 403/404, never leak
- JWT expiry mid-session → silent refresh, re-auth on failure
- Offline operation → cached reads, queued writes with idempotency
- Concurrent updates → optimistic lock (
versionfield), 409/422 conflict - Soft-deleted records never appear in lists
- Rate limiting (429) on auth + API tiers
- Large lists (>100 rows) → pagination, not infinite scroll alone
- Notification fan-out failures → retry + DLQ, user sees "pending" not "failed"
9. Global assumptions
- PRD conflict (flagged):
PRODUCT_REQUIREMENTS_DOCUMENT.md:144puts native mobile apps out of Phase 1 scope; roadmap Phase 3 plans a read-only companion. Decision made with the product owner: these docs specify a full-featured Flutter client now, to the complete API surface. Any conflict with the web-first roadmap is resolved in favor of these docs unless the roadmap is amended. - No UI design system exists in the repo → Material 3 tokens in 02_Design_Tokens.md are proposed defaults, marked as such, until a designer finalizes them.
- No device/push-token registry exists → push-related flows are forward-looking, flagged.
- Client architecture (Bloc/Cubit) is a recommendation, not an implemented decision.
- API endpoints marked "planned" come from
docs/IMPLEMENTATION_PLAN.mdand may not be in code yet; they are flagged(planned)in every doc.
10. Glossary
| Term | Meaning |
|---|---|
| Tenant | One institution (organization) with isolated data |
| Org Admin | Tenant-level administrator |
| Permission | resource.action string (see permissions.constants.ts) |
| Envelope | Standard API response wrapper {success, message, data, timestamp, requestId} |
| Soft delete | isDeleted: true; excluded from all queries |
| DLQ | Dead-letter queue for failed BullMQ jobs |
| BaseSchema fields | tenantId, createdBy, updatedBy, isDeleted, deletedAt, deletedBy, version, createdAt, updatedAt |
| Academic structure | AcademicYear → Grade → Class → Section; subjects assigned to classes |
| Bulk import | CSV upload processed asynchronously by workers |
02 — Design Tokens (Shared)
- 1. Color system
- 2. Typography
- 3. Spacing
- 4. Radius
- 5. Elevation & shadow
- 6. Motion
- 7. Iconography
- 8. Breakpoints (responsive)
- 9. Touch targets
- 10. Light/dark parity rules
STATUS: v1 — designer-provided (Material 3 token export from Figma). Rows marked TBD by designer or (extrapolated…) are not sourced from the export and need designer sign-off before shipping. All module docs reference this file; changes here propagate everywhere.
1. Color system
1.1 Chromatic roles (light)
Designer-provided M3 roles plus two custom extended roles (success, warning —
not standard M3, present in the token set).
| Role | Token Name | Hex | Usage |
|---|---|---|---|
primary | International Klein Blue | #0037B0 | CTAs, active nav, links, focus |
onPrimary | White | #FFFFFF | Text/icons on primary |
primaryContainer | Titan White | #E9EDFF | Selected chips, info banners |
onPrimaryContainer | International Klein Blue | #0037B0 | Text on primaryContainer |
secondary | Persian Blue | #1D4ED8 | Secondary CTAs |
onSecondary | White | #FFFFFF | Text/icons on secondary |
secondaryContainer | Pattens Blue | #DBEAFE | Tonal buttons, selection indicators |
onSecondaryContainer | Persian Blue | #1D4ED8 | Text on secondaryContainer |
tertiary | Purple Heart | #712AE2 | Distinct accents, highlights |
onTertiary | White | #FFFFFF | Text/icons on tertiary |
tertiaryContainer | Blue Chalk | #EADDFF | Tonal tertiary surfaces |
onTertiaryContainer | Purple Heart | #712AE2 | Text on tertiaryContainer |
error | Thunderbird | #BA1A1A | Validation, destructive |
onError | White | #FFFFFF | Text on error |
errorContainer | Cinderella | #FEE2E2 | Error banners, destructive surfaces |
onErrorContainer | Sangria | #93000A | Text on errorContainer |
success (custom) | Salem | #16A34A | Paid, present, approved, healthy |
onSuccess (custom) | White | #FFFFFF | Text/icons on success |
successContainer (custom) | Scandal | #DCFCE7 | Success surfaces, badges |
onSuccessContainer (custom) | Jewel | #15803D | Text on successContainer |
warning (custom) | Ecstasy | #F97316 | Partial, late, attention |
onWarning (custom) | White | #FFFFFF | Text/icons on warning |
warningContainer (custom) | Serenade | #FFF7ED | Warning surfaces, badges |
onWarningContainer (custom) | Cedar Wood Finish | #7F2500 | Text on warningContainer |
Note:
successandwarningare standalone roles, not aliases ofsecondary/tertiary(previous proposal). Dues/warnings that were formerlytertiarynow map towarning; paid/healthy states map tosuccess.
1.2 Surface scale (light)
| Role | Token Name | Hex | Usage |
|---|---|---|---|
background | Catskill White | #F8FAFC | Screen background |
onBackground | Ebony | #0F172A | Primary text on background |
surface | White | #FFFFFF | Card/screen surfaces |
onSurface | Ebony | #0F172A | Primary text |
surfaceVariant | Mystic | #E2E8F0 | Input fills, chips |
onSurfaceVariant | Fiord | #475569 | Secondary text, icons |
surfaceContainerLowest | White | #FFFFFF | Flat cards on containers |
surfaceContainerLow | Catskill White | #F8FAFC | Card backgrounds |
surfaceContainer | Athens Gray | #E5E7EB | Raised surfaces, nav bars |
surfaceContainerHigh | Geyser | #CBD5E1 | Elevated cards, dialogs |
surfaceContainerHighest | Gull Gray (extrapolated — next step down the neutral scale) | #94A3B8 | TBD by designer |
surfaceDim | Slate Gray (extrapolated — dimmest step before onSurface tones) | #64748B | TBD by designer |
surfaceBright | White (extrapolated — brightest neutral, standard M3 convention) | #FFFFFF | TBD by designer |
1.3 Outline & inverse
| Role | Token Name | Hex |
|---|---|---|
outline | Boulder | #767676 |
outlineVariant | Geyser | #CBD5E1 |
shadow | Black | #000000 |
scrim | Black | #000000 |
inverseSurface | Mirage | #141B2B |
inverseOnSurface | Catskill White | #F8FAFC |
inversePrimary | Melrose | #B7C4FF |
1.4 Dark mode
Designer-provided dark surface scale (grey/azure family):
| Level | Token Name | Hex |
|---|---|---|
| Surface Dim | Ebony | #0F172A |
| Surface | Mirage | #141B2B |
| Surface Container | Ebony Clay | #293040 |
| Surface Container High | Pickled Bluewood | #334155 |
| Surface Container Highest | Slate Gray | #64748B |
- Dark chromatic roles (primary/secondary/tertiary/error/success/warning + containers) are NOT in the export — TBD by designer.
- Working proposal until then (per M3 convention, dark primary ≈ inversePrimary): dark
primary≈#B7C4FF(Melrose) withonPrimary≈#0037B0-family dark. Do not ship dark mode until confirmed.
1.5 Tenant branding
Org branding.color overrides primary only; all other roles stay from this palette.
Dark mode default: follow system; org branding can pin one.
1.6 Raw named palette (designer reference)
| Token | Hex | Notes |
|---|---|---|
| Athens Gray | #E5E7EB | neutral |
| Beeswax | #FEF9C3 | tertiary tint |
| Black | #000000 | scrim / shadow |
| Blue Chalk | #EADDFF | tertiaryContainer |
| Boulder | #767676 | outline |
| Brown Pod | #390C00 | error-dark accent |
| Catskill White | #F8FAFC | surface/background |
| Cedar Wood Finish | #7F2500 | onWarningContainer |
| Cinderella | #FEE2E2 | errorContainer |
| Ebony | #0F172A | onSurface (dark) |
| Ebony Clay | #293040 | surface container (dark) |
| Ecstasy | #F97316 | warning |
| Fiord | #475569 | onSurfaceVariant |
| Fire | #A73400 | error-dark accent |
| Flamingo | #EF4444 | error accent |
| Foam | #ECFDF5 | success tint |
| Fog | #DCE1FF | primary tint |
| Fun Green | #008542 | success accent |
| Geyser | #CBD5E1 | outlineVariant |
| Ghost | #C4C5D7 | neutral variant |
| Gray Chateau | #9CA3AF | neutral |
| Green Haze | #059669 | success accent |
| Gull Gray | #94A3B8 | neutral |
| Harp | #F1F8F4 | success tint |
| Hawkes Blue | #E1E8FD | secondaryContainer alt |
| Humming Bird | #D1FAE5 | success tint |
| International Klein Blue | #0037B0 | primary |
| Jewel | #15803D | onSuccessContainer |
| Link Water | #DCE2F7 | neutral variant |
| Magic Mint | #A7F3D0 | success tint |
| Mako | #434655 | neutral dark |
| Melrose | #B7C4FF | inversePrimary |
| Mirage | #141B2B | inverseSurface |
| Mountain Meadow | #10B981 | success accent |
| Mystic | #E2E8F0 | surfaceVariant |
| Pale Sky | #6B7280 | neutral |
| Pattens Blue | #DBEAFE | secondaryContainer |
| Peach Schnapps | #FFDAD6 | error tint |
| Periwinkle | #CAD3FF | primary tint |
| Persian Blue | #1D4ED8 | secondary |
| Pickled Bluewood | #334155 | surface container (dark) |
| Provincial Pink | #FEF2F2 | error tint |
| Pueblo | #7C2D12 | warning-dark accent |
| Purple Heart | #712AE2 | tertiary |
| Rich Gold | #A16207 | warning-dark accent |
| Romantic | #FFC9B7 | warning tint |
| Salem | #16A34A | success |
| Sangria | #93000A | onErrorContainer |
| Scandal | #DCFCE7 | successContainer |
| Serenade | #FFF7ED | warningContainer |
| Slate Gray | #64748B | neutral |
| Storm Gray | #747686 | outline alt |
| Thunderbird | #BA1A1A | error |
| Titan White | #E9EDFF | primaryContainer |
| Watercourse | #047857 | success accent |
| Watusi | #FFDBCF | warning tint |
| White | #FFFFFF | onPrimary / surface |
| Zircon | #F1F3FF | neutral tint |
| Zumthor | #EFF6FF | secondary tint |
2. Typography
Font families: Inter (UI) and JetBrains Mono (data/identifiers). Both designer-provided.
2.1 M3 type scale mapping
Rows marked (extrapolated…) were not present as named tokens in the export; they are filled from the nearest
fontSizescale steps or adjacent tiers per standard M3 conventions. TBD by designer — confirm before shipping.
| M3 Scale Role | Mapped Token | Font | Weight | Size | Line Height | Letter Spacing | Case |
|---|---|---|---|---|---|---|---|
| Display Large | Heading 1 underline | Inter | Extra Bold | 48px | 48px | -1.2 | none |
| Display Medium | (extrapolated, fontSize step 14) | Inter | Bold | 36px | 44px | -1.2 | none |
| Display Small | (extrapolated, fontSize step 13) | Inter | Bold | 28px | 34px | -0.72 | none |
| Headline Large | Heading 1 | Inter | Semi Bold | 20px | 28px | -0.5 | none |
| Headline Medium | (extrapolated — same tier as Heading 1, heavier) | Inter | Bold | 20px | 28px | -0.5 | none |
| Headline Small | Heading 2 | Inter | Bold | 18px | 27px | 0 | none |
| Title Large | Data | Inter | Semi Bold | 16px | 24px | 0 | none |
| Title Medium | Heading 3 | Inter | Bold | 14px | 21px | 0 | none |
| Title Small | Inter / Medium | Inter | Medium | 14px | 20px | 0 | none |
| Body Large | Inter / Regular | Inter | Regular | 14px | 20px | 0 | none |
| Body Medium | Input | Inter | Regular | 14px | AUTO | 0 | none |
| Body Small | Label | Inter | Regular | 10.7px | 16.5px | 0 | none |
| Label Large | Button | Inter | Bold | 12px | 18px | 0 | none |
| Label Medium | Button upper / Cell upper | Inter | Bold | 12px | 16px | 1.2 / 0.6 | uppercase |
| Label Small | Link | Inter | Regular | 12px | 18px | 0 | none |
2.2 Non-M3 typography tokens (designer-provided)
| Token | Font | Weight | Size | Line Height | Case |
|---|---|---|---|---|---|
| Inter / Semi Bold upper | Inter | Semi Bold | 10px | 15px | uppercase |
| Inter / Regular upper | Inter | Regular | 10px | 15px | uppercase |
| Inter / Bold upper | Inter | Bold | 10px | 15px | uppercase |
| Inter / Italic | Inter | Italic | 16px | 26px | none |
| Inter / Bold | Inter | Bold | 16px | 24px | none |
| JetBrains Mono / Regular | JetBrains Mono | Regular | 13px | 18px | none |
| JetBrains Mono / Bold | JetBrains Mono | Bold | 13px | 18px | none |
Usage: mono for IDs, roll numbers, amounts, keys. Numerals (scores, amounts) use
FontFeature.tabularFigures().
3. Spacing
3.1 Semantic scale (designer-provided)
| Token | px | Usage |
|---|---|---|
xs | 8 | Tight gaps between inline elements |
s | 16 | Screen horizontal padding, card padding, form field gap |
item-spacing.24 | 24 | Item spacing, section gaps |
m | 32 | Grouping gaps, modal padding |
l | 48 | Large section separation |
xl | 64 | Screen-level separation, toolbar height |
3.2 Fixed numeric scale (designer-provided)
0, 4, 5, 6, 7 px (micro gaps), 35, 53 px (special layout), and
negative offsets -12, -2, -1 px.
The export also contains many decimal auto-layout "gap" values (e.g.
130,238,981.25,1520.55) captured directly from Figma frames — not a spacing scale; do not reuse. TBD by designer for scale rationalization.
3.3 Layout conventions (from previous proposal, mapped to new scale)
| Use | Value | Token |
|---|---|---|
| Screen horizontal padding | 16 | s |
| Card padding | 16 | s |
| Card gap | 12 | derived (half of item-spacing.24) — TBD by designer |
| Section gap | 24 | item-spacing.24 |
| List item height | ≥ 56 | — |
| Form field gap | 16 | s |
| Toolbar height | 64 | xl |
4. Radius
| Token | Value | Source | Usage |
|---|---|---|---|
r-micro | 2.5px | corner radius.2_5 | Tags, dense chips |
r-xs | 4px | (extrapolated — standard M3 4dp) TBD by designer | Small tags |
r-sm | 8px | corner radius.8 | Inputs, buttons, cards |
r-md | 12px | corner radius.12 | Cards (elevated), dialogs |
r-lg | 16px | (extrapolated — standard M3 16dp) TBD by designer | Bottom sheets, FAB |
r-xl | 28px | (extrapolated — standard M3 28dp) TBD by designer | Sheets, large surfaces |
r-full | 9999px | corner radius.9999 | Avatars, pills |
5. Elevation & shadow
No elevation/shadow tokens exist in the export. The dp scale below is a proposal built from
Black+ the export'sopacitytokens (shadow color#000000at 10% per step). TBD by designer — every row is a starting point.
| Token | dp | Suggested shadow |
|---|---|---|
e-0 | 0 | none |
e-1 | 1 | 0 1px 2px rgba(0,0,0,0.10), 0 1px 3px rgba(0,0,0,0.10) |
e-2 | 3 | 0 1px 2px rgba(0,0,0,0.10), 0 2px 6px rgba(0,0,0,0.10) |
e-3 | 6 | 0 1px 3px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.10) |
e-4 | 8 | 0 2px 3px rgba(0,0,0,0.10), 0 6px 10px rgba(0,0,0,0.10) |
e-5 | 12 | 0 4px 4px rgba(0,0,0,0.10), 0 8px 12px rgba(0,0,0,0.10) |
Supporting export tokens: opacity.10/50/60/70/80/90/100 (scrim/overlay steps),
stroke weight.1 (1px) / .2 (2px) for borders, shadow = Black.
6. Motion
No motion tokens in the export — still proposed (previous status).
| Token | Duration | Curve | Usage |
|---|---|---|---|
m-instant | 0 ms | — | Value changes, icon toggles |
m-fast | 150 ms | easeOutCubic | Hover, focus, ripple, small state |
m-base | 250 ms | easeInOutCubic | Screen transitions, dialogs, sheets |
m-slow | 400 ms | easeInOutCubic | Hero, page fade, skeletons |
m-entrance | 300 ms | easeOutBack | List item entrance (stagger 40 ms) |
Reduce-motion (platform setting): m-instant for all, no parallax, no stagger.
7. Iconography
No icon tokens in the export — Material Symbols remains the proposal (previous status). Confirm pack with designer before build.
Material Symbols (outlined, weight 400, optical 24). Sizes: 16/20/24/32/40.
Status icons use filled variant. All icons need a text label or tooltip+Semantics.label.
8. Breakpoints (responsive)
| Class | Width | Layout |
|---|---|---|
| Phone | < 600 dp | Bottom nav, single pane |
| Tablet | 600–839 dp | Bottom nav + wider panes, 2-column lists |
| Landscape tablet | 840–1199 dp | Navigation rail, master-detail |
| Desktop/web | ≥ 1200 dp | Navigation rail/drawer, master-detail, hover states |
9. Touch targets
Minimum 48 × 48 dp tappable; list rows ≥ 56 dp; FAB 56 dp; 44 dp acceptable for
dense inline controls (chip remove, icon buttons with tooltips).
10. Light/dark parity rules
- No color hardcoded in widget code — always token references
- Surface contrast ≥ 3:1 for UI chrome, ≥ 4.5:1 for text
- Elevation substitutes for color on dark surfaces
- Both modes must pass the QA color checklist (see 10_QA_Baseline.md)
- Dark chromatic roles are TBD by designer (§1.4) — gate dark-mode ship on sign-off
03 — Component Library (Shared)
- A. Feedback & states
- B. Inputs
- C. Display
- D. Navigation & actions
- E. Content & media
- F. Shared interaction patterns
- G. Golden-test coverage expectation
Every reusable client component shared across modules. Module docs 07_Component_Library.md list module-specific components and reference these by name. Flutter mapping assumes Material 3 (
flutter/material,Material3ThemeData).
A. Feedback & states
AppSkeleton
- Purpose: loading placeholder for lists/cards/text.
- Props:
variant(text|circle|rect|list|card),width,height,lines,radius. - States: shimmer loop (
m-slow), reduced-motion = static tint. - Tokens:
surfaceVariantfill,e-0. Flutter: customSkeletonwidget orshimmerpackage wrapper (prefer custom to avoid dep).
AppEmptyState
- Purpose: empty/zero-result content.
- Props:
icon,title,message,actionLabel,onAction,compact. - States: default; compact (inline list header).
- A11y: container
Semantics(label: title + message). - Tokens:
displaySmalltitle,bodyMediumonSurfaceVariant. Flutter:Column+Icon+Text+FilledButton.tonal.
AppErrorState
- Purpose: full-screen or inline fetch failure.
- Props:
code(error code from envelope),message,onRetry,details?,compact. - Rules: never expose stack/server internals; 500 → generic text + requestId hint.
- Tokens:
erroricon,titleLarge,bodyMedium. Flutter:ErrorWidget-free custom widget.
AppOfflineBanner
- Purpose: top banner when connectivity lost.
- Props:
visible,text(i18noffline.banner),onRetry. - Behaviour: auto-dismiss on connectivity restore; sticky at top,
e-1. - Tokens:
tertiaryContainerbackground. Flutter:MaterialBanneror customAnimatedContainer.
AppSnackbar / AppToast
- Purpose: transient feedback (success, error, info, undo).
- Props:
type,message,actionLabel?,onAction?,duration. - Rules: one snackbar at a time (queue),
errortype persists ≥ 4 s, success < 3 s. - Tokens: inverse surface,
error/successicon accents. Flutter:ScaffoldMessengerwrapper.
AppBanner
- Purpose: persistent inline message (e.g., "results published", "pending KYC docs").
- Props:
severity(info|success|warning|error),title,body?,dismissible. - Tokens:
*Containerrole colors. Flutter:MaterialBanner.
B. Inputs
AppTextField
- Props:
label,hint,placeholder,keyboardType,textInputAction,formatters,validator,maxLength,prefix/suffixIcon,obscure,autofillHints,errorText,helperText,enabled,autofocus. - States: empty, filled, focused, error, disabled, read-only, loading (suffix spinner).
- Validation:
validatorrun on submit + on change after first error; error clears on edit. - A11y:
label+Semantics(error)on error; error announced. - Tokens:
surfaceVariantfill,outlineborder,erroron error, focusprimary. - Flutter:
TextField/TextFormFieldwithInputDecoration,InputFormatters.
AppDropdown (single select)
- Props:
label,items[{value,label}],value,onChanged,searchable?,loading?,emptyText,errorText,disabled. - Behaviour: opens
AppBottomSheetpicker or menu; selected value shown in field. - A11y: role "combobox",
Semanticson options. - Flutter: custom field +
showModalBottomSheet, orDropdownButtonFormFieldfor ≤ 8 items.
AppSearchBar
- Props:
hint,debounceMs(default 300),onQuery,autofocus,suffix(clear),trailing(filter chips button). - Behaviour: debounced server search; clear restores list; stays on screen during search.
- A11y: label, results announced (
liveRegion). - Flutter:
SearchBar(M3) with controller + debounceTimer.
AppChips (filter/tag/input)
- Props:
label,selected,onToggle,avatar?,onDelete?(input chips). - Flutter:
FilterChip/InputChip/ActionChip.
AppDatePicker / AppTimePicker
- Props:
label,value,min/maxDate,onChanged,errorText. - Behaviour: opens M3 date/time picker dialog; locale-aware formatting;
m-basetransition. - Flutter:
showDatePicker/showTimePickerwrappers;MaterialLocalizations.
AppSwitch / AppCheckbox / AppRadio
- Standard M3 controls; props:
label,value,onChanged,disabled,subtitle?. - A11y: label semantics,
Semantics(toggled:)/checked:. - Flutter:
Switch,Checkbox,RadiowithListTilewrappers.
AppSlider
- Props:
label,min/max/divisions,value,onChanged,valueLabel. - Flutter:
Slider+SliderTheme.
C. Display
AppCard
- Props:
title?,subtitle?,trailing?,leading?,onTap?,elevation(e-0/e-1),padding,child. - Variants: flat (
e-0), outlined (outlineVariant), tonal (primaryContainer). - A11y: whole-card tap = one
Semantics(button:); inner controls excluded from card tap. - Flutter:
Card/Card.filledwithInkWell.
AppListTile
- Props:
leading,title,subtitle,trailing,onTap,dense?,selected?,badge?,menu?(trailing overflow menu). - Height ≥ 56; dense ≥ 48. Flutter:
ListTilewith customMenuAnchor.
AppAvatar
- Props:
name,photoUrl?,size(24/32/40/48/64),presence?,onTap. - Fallback: initials from name on
primaryContainer. Flutter:CircleAvatar+CachedNetworkImage-style cache.
AppBadge
- Props:
text,color(role),icon?,outlined?. Flutter:Badge/customContainer.
AppProgress
LinearProgressIndicator(paged loads) /CircularProgressIndicator(buttons, refresh).- Tokens:
primary. Reduced-motion: determinate only.
AppTabs
- Props:
tabs[{label, icon?, badge?}],controller,scrollable?,onChanged. - Flutter:
TabBar+TabBarView(keep-alive lists).
AppCharts
- Purpose: KPI visualization (dashboard, reports, fees).
- Props:
type(bar|line|donut|gauge),series,labels,emptyText,height. - Rules: show values in tooltips + data labels; a11y: provide
Semanticssummary text; provide tabular fallback when charts not meaningful. - Flutter:
fl_chart(recommended, already industry standard; verify license) — wrap in ownAppChartwidget so the lib is swappable.
AppAvatarStack, AppStatTile, AppInfoRow
- StatTile:
label, value, delta?, trend?for dashboards. - InfoRow:
label, value, icon?for detail screens.
D. Navigation & actions
AppFAB
- Props:
icon,label?(extended),onPressed,heroTag(unique per screen!). - Rules: one FAB per screen; hidden on scroll-down, shown on scroll-up (list screens).
- Flutter:
FloatingActionButton/FloatingActionButton.extended; scroll listener.
AppButton (M3)
- Variants:
filled(primary CTA),filledTonal(secondary),outlined(tertiary),text(inline),icon(icon button w/ tooltip). - Props:
label,icon?,loading?(spinner replaces icon),enabled,destructive?(error colors),fullWidth?,semanticLabel. - Height 40/48; touch target ≥ 48.
- Flutter:
FilledButton,FilledButton.tonal,OutlinedButton,TextButton,IconButtonwithTooltip.
AppDialog
- Props:
title,content,actions,dismissible(barrier),destructiveAction?. - Behaviour: barrier tap = cancel unless
dismissible=false; ESC/back closes; focus moves into dialog;m-basescale+fade. - Flutter:
showDialog+AlertDialog;FocusScope.
AppBottomSheet
- Props:
title,draggable(drag handle),showDragHandle,children,isDismissible,height(half/full). - Rules: used for pickers, actions, filters; returns value via
Navigator.pop(result). - Flutter:
showModalBottomSheetwithDragHandle(M3).
AppMenu (context menu)
- Props:
items[{label, icon?, destructive?, onTap}],semanticLabel. - Behaviour: long-press / trailing overflow opens
MenuAnchor; ESC closes; focus ring. - Flutter:
MenuAnchor+MenuItemButton.
AppTabBarTop + AppNavigationBar + AppNavigationDrawer + AppNavigationRail
- AppNavigationBar: bottom nav for phones (3–5 destinations,
AppDestinationslist). - AppNavigationDrawer: full menu (all sections) for large screens; slide-in on phone via hamburger.
- AppNavigationRail: tablet/desktop ≥ 840 dp.
- Rules: selected destination =
primaryindicator (pill); badges show unread counts. - Flutter:
NavigationBar,NavigationDrawer,NavigationRail.
E. Content & media
AppSectionHeader
- Props:
title,actionLabel?,onAction?,subtitle?. Section gaps24top. - Flutter:
RowwithtitleMedium+TextButton.
AppAttachmentTile
- Props:
name,size,type,url,onPreview,onDownload?,uploading?,onRemove?. - Preview: PDF/image viewers; files open in external viewer via share sheet.
- Flutter:
ListTile+ file-type icon map.
AppMarkdownViewer
- Purpose: homework descriptions, announcements, notices.
- Props:
data,compact?,onLinkTap. - Flutter:
flutter_markdown(wrap; sanitize links).
AppStepper
- Purpose: multi-step flows (enrollment, admission, bulk import wizard).
- Props:
steps[{title, state}],currentIndex,onStepTap?. - Flutter: custom horizontal stepper; vertical on desktop.
AppQRScanner
- Purpose: biometric check-in, transport pickup, library issue (forward-looking: requires backend QR code support — see assumptions).
- Props:
onScanned,pauseAfterScan,flashToggle?. - Flutter:
mobile_scannerpackage (wrap).
F. Shared interaction patterns
- Pull-to-refresh on all scrollable lists →
RefreshIndicator; triggers reload + WS resubscribe. - Infinite scroll for paginated lists → bottom
CircularProgressIndicator+m-instantfade; "end of list" footer withbodySmalltext. - Optimistic updates on mark-read, toggles, likes; rollback on error + snackbar.
- Undo pattern: destructive-ish non-soft actions (delete homework draft, remove attachment) → snackbar with UNDO within 4 s.
- Empty→loading→error state machine per screen, never blank.
G. Golden-test coverage expectation
Every shared component has a golden test at 3 sizes (phone/tablet/desktop) + dark mode.
04 — Design System Mapping (Shared)
- 1. Theme
- 2. Widget → token mapping (core)
- 3. Typography mapping
- 4. Iconography
- 5. Motion mapping
- 6. Layout tokens
- 7. Theming rules (enforced)
- 8. Web/desktop (Flutter Web + Windows/macOS/Linux)
Maps Material 3 + Flutter widgets to the StudyLyon token set. Module docs 11_Design_System_Mapping.md extend this with module-specific components.
1. Theme
| Concern | Value |
|---|---|
| Framework | Flutter Material 3 (useMaterial3: true) |
ColorScheme | ColorScheme.fromSeed(seedColor: primary) + overrides from 02_Design_Tokens.md |
ThemeData | One AppTheme.light() / AppTheme.dark(); themeMode from system or org branding |
| Text theme | TextTheme from 02_Design_Tokens.md §2 |
| Spacing | AppSpacing extension class (semantic xs/s/item-spacing.24/m/l/xl + fixed scale) |
| Radius | AppRadius class (r-micro … r-full) |
| Elevation | AppElevation class; shadow tokens via BoxShadow/elevation params |
| Motion | AppMotion class (durations + Curves) |
| Localization | flutter_localizations + Intl; MaterialApp.localizationsDelegates |
| Fonts | Theme font Inter (default); mono via JetBrains Mono (fontFamilyFallback) |
2. Widget → token mapping (core)
| Flutter widget | M3 role | StudyLyon token |
|---|---|---|
FilledButton | Primary | primary/onPrimary, r-md, m-fast ripple |
FilledButton.tonal | Secondary | secondaryContainer/onSecondaryContainer |
OutlinedButton | Tertiary | outline, onSurface, r-md |
TextButton | Text action | primary |
IconButton | Icon action | onSurfaceVariant, tooltip required |
TextField | Filled input | fill surfaceVariant, border outline, focus primary, r-md |
Card | Elevated card | surfaceContainerLow, e-1, r-lg |
ListTile | List row | surface, height 56, onSurface/onSurfaceVariant |
NavigationBar | Bottom nav | surfaceContainer, indicator secondaryContainer, 64 h |
NavigationRail | Tablet/desktop nav | surfaceContainerLow, indicator secondaryContainer |
NavigationDrawer | Drawer | surfaceContainerLow, e-4, width 320 |
AppBar | Top bar | surface, titleLarge, centerTitle false (phone), true (desktop) |
TabBar | Section tabs | indicator primary, labelLarge |
FloatingActionButton | Primary action | primaryContainer, e-3, 56 dp |
Dialog/AlertDialog | Dialog | surfaceContainerHigh, e-4, r-lg |
BottomSheet | Sheet | surfaceContainerHigh, e-5, r-xl top corners |
SnackBar | Toast | inverseSurface, m-base slide |
Chip (Filter/Input) | Chip | selected secondaryContainer, else surfaceVariant |
Switch/Checkbox/Radio | Toggle | primary selected, onSurfaceVariant unselected |
Slider | Slider | primary, e-0 |
LinearProgressIndicator | Progress | primary, surfaceVariant track |
RefreshIndicator | Pull-refresh | primary spinner |
Badge | Badge | error for counts, tertiary for dues |
Tooltip | Tooltip | inverseSurface, m-fast |
Hero | Hero transition | m-slow flight |
Semantics | A11y labels | all interactive nodes labeled |
3. Typography mapping
| M3 token | TextTheme member | Typical widget |
|---|---|---|
| displaySmall | displaySmall | Empty states |
| headlineMedium | headlineMedium | Screen titles |
| titleLarge | titleLarge | Card/dialog titles |
| titleMedium | titleMedium | Section headers, list titles |
| bodyLarge | bodyLarge | Body, descriptions |
| bodyMedium | bodyMedium | Labels, secondary |
| bodySmall | bodySmall | Captions, timestamps |
| labelLarge | labelLarge | Buttons, tabs |
| labelMedium | labelMedium | Badges |
| labelSmall | labelSmall | Overlines |
4. Iconography
- Pack: Material Symbols outlined; 24 dp default; status icons filled.
- Sizing: 16 (dense rows), 20 (menu), 24 (default), 32 (empty state), 40 (headers).
- Color:
onSurfaceVariantdefault;primaryactive;errordestructive.
5. Motion mapping
| Use | Token | Flutter |
|---|---|---|
| Page transition | m-base 250 ms | PageTransitionsTheme (FadeUpwards on Android, Cupertino on iOS) |
| Dialog/sheet | m-base | showDialog/showModalBottomSheet defaults |
| Hero | m-slow 400 ms | Hero widget |
| Ripple | m-fast 150 ms | InkWell/InkResponse |
| Hover/focus | m-fast | MouseRegion + AnimatedContainer/focus ring |
| List entrance | m-entrance stagger 40 ms | AnimatedList/explicit TweenAnimationBuilder |
| Skeleton shimmer | m-slow loop | custom AnimatedBuilder |
| Snackbar | m-base | ScaffoldMessenger |
6. Layout tokens
| Token | Value |
|---|---|
| Screen horizontal padding | 16 (phone), 24 (tablet/desktop) |
| Card padding | 16 |
| Card gap | 12 |
| Section gap | 24 |
| Toolbar | 64 |
| Bottom nav height | 64 |
| List row | 56 |
| Master-detail breakpoint | 840 dp |
7. Theming rules (enforced)
- No literal
Colorin widget code —context.colorScheme.*/AppTokens.*only. - No literal padding/margin —
AppSpacing.*only. - No literal durations —
AppMotion.*only. - Dark mode is a first-class theme, not an overlay.
- Tenant branding overrides
primaryat runtime viaThemeDatacopy.
8. Web/desktop (Flutter Web + Windows/macOS/Linux)
- Hover states on all tappables (
MouseRegion), focus rings (FocusTraversalGroup), keyboard shortcuts (Shortcuts/Actions), ESC closes dialogs/sheets/menus. - Responsive layout via
LayoutBuilderbreakpoints (02_Design_Tokens.md §8). - Scrollbars always visible on desktop.
05 — Global Information Architecture (Shared)
- 1. App shell
- 2. Top-level destinations (role-gated)
- 3. Navigation model
- 4. Routing table (shared)
- 5. Modal hierarchy (shared)
- 6. Screen relationships
- 7. Quick actions & FABs
- 8. Cross-cutting screens owned by shared package
- 9. Roles → permission model (client-side)
The app shell, navigation model, routing, and role-based surface mapping that every module slots into. Module docs 04_Information_Architecture.md describe their own screens and where they live in this shell.
1. App shell
MaterialApp
└─ AppShell (role-aware)
├─ NavigationBar (phone) / NavigationRail (tablet+)
├─ NavigationDrawer (hamburger, all platforms)
├─ AppBar (contextual per screen)
├─ Router (go_router, declarative)
└─ Scaffold body = module pages
AppShellresolves current role + permissions once at login and on refresh; it drives which destinations and routes are reachable.- Unauthorized destinations are hidden and unroutable (guard rejects → 403 screen or redirect to home).
2. Top-level destinations (role-gated)
| Destination | Route prefix | Roles (default) |
|---|---|---|
| Home / Dashboard | /home | all |
| Students | /students | admin, staff, teacher (read) |
| Attendance | /attendance | teacher, admin |
| Academics | /academics | admin, teacher (read) |
| Exams & Results | /exams | teacher, admin; results read: parent/student |
| Fees | /fees | accountant, admin; read: parent |
| Communication | /messages, /announcements | all |
| Notifications | /notifications | all |
| CRM (Leads/Admissions) | /crm | admin, staff |
| Staff | /staff | admin, HR |
| Library | /library | librarian, admin, student (read) |
| Transport | /transport | transport manager, admin |
| Timetable | /timetable | teacher, admin, student (read) |
| Leave | /leave | staff (self), admin |
| Homework | /homework | teacher, student, parent (read) |
| Reports | /reports | admin, accountant |
| Settings | /settings | admin |
| Users & Roles | /users, /roles | admin |
| Organization | /organization | admin |
| Search | /search | all (scoped by role) |
Default landing per role: Student → Home; Teacher → Attendance/Today; Parent → Home; Admin → Dashboard; Accountant → Fees.
3. Navigation model
- Phone (< 600 dp): bottom
NavigationBar(max 5 destinations; more via drawer), drawer for the rest, stack navigation withgo_routerStatefulShellRoute. - Tablet (600–1199 dp):
NavigationRail+ master-detail; detail in right pane. - Desktop (≥ 1200 dp): rail or permanent drawer; master-detail; keyboard nav.
- Nested navigation: module shells use
StatefulShellBranchso tab state survives bottom-nav switches. - Breadcrumbs: desktop only, on detail screens deeper than 2 levels.
- Global search: AppBar search icon →
/search(all roles, results scoped).
4. Routing table (shared)
| Route | Screen | Auth |
|---|---|---|
/login | Login | public (redirects to /home if authed) |
/login/2fa | 2FA challenge | public |
/register | Tenant registration | public |
/verify-email | Email verification | public |
/reset-password | Password reset | public |
/home | Dashboard/Home | authed |
/notifications | Notification center | authed |
/search | Global search | authed |
/settings | Settings | authed |
/users/:id, /roles/:id, … | module routes | authed + permission |
Deep links:
studylyon://notifications/:id→ notification detail (marks read)studylyon://homework/:id,studylyon://results/:id,studylyon://messages/thread/:id,studylyon://attendance/:date,studylyon://invoice/:id(module docs enumerate their own)
5. Modal hierarchy (shared)
- Snackbar/Toast — transient, never blocks.
- Bottom sheet — pickers, filters, quick actions (half/full height).
- Dialog — confirmations, forms ≤ 3 fields, destructive confirms.
- Full-screen page — forms with > 3 fields, detail screens, wizards.
- Loader — only when nothing else can render (skeletons preferred).
Rule: destructive actions need explicit dialog confirm; irreversible server-side ops
(double-delete, purge) need typed confirm (AppDialog + text input of entity name).
6. Screen relationships
- List → Detail → Edit/Form; Edit returns → Detail (refresh); Detail → related lists (e.g., Student → Attendance, Fees, Results) via tabs.
- Master-detail on tablet/desktop; push-on-top on phone.
- Context menus on list rows for quick actions (edit, duplicate, delete, more).
7. Quick actions & FABs
- One FAB per screen, module-defined (e.g., Students → "Add student", Attendance → "Mark today").
- Context menus (
AppMenu) for row actions; swipe actions on phone for common ops (mark read, delete draft, archive). - Keyboard: desktop
Ctrl+K→ global search;Esccloses modal/sheet;+orNnew on list screens (documented per module).
8. Cross-cutting screens owned by shared package
- 403 Permission screen, 404 Not Found, 500 Error screen, Offline screen
- Session expiry overlay (silent refresh failed) → re-login
- Global search results shell
- Notification center shell
- Account/profile (self-service): avatar menu → profile, password, 2FA, devices, preferences
9. Roles → permission model (client-side)
- Client mirrors
permissions.constants.ts; route guards checkuser.permissions.contains('x.y'). - Server remains authoritative: client 403/404 handling matches server error codes
(
PERMISSION_DENIED,RESOURCE_NOT_FOUND). - Custom roles: permission sets resolve per tenant; client rebuilds routes on role change.
06 — State Management (Shared Architecture)
- 1. Stack
- 2. Layering
- 3. Base patterns (shared)
- 4. Event/state conventions
- 5. Error handling (client)
- 6. Testing hooks
Recommended client state architecture (Bloc/Cubit). Decision status: proposal — flagged because no client exists yet. Module docs
13_State_Management.mddefine per-screen states on top of these conventions.
1. Stack
flutter_bloc(Bloc + Cubit). Why: strict state transitions, testability, team familiarity.- No additional state libs; DI via
get_itorprovider— recommendget_it+ injectable. - Server state: repository layer with
dio; cache viaHive/shared_preferencesfor small config anddriftonly if offline queues grow (YAGNI: start with in-memory +shared_preferences).
2. Layering
UI (widgets) → Cubit/Bloc → Repository → DataSource (dio/WS) → API
└─ Cache (memory / prefs)
- Widgets never call repositories directly; they emit events / read state.
- Repositories are the only layer touching HTTP; they map envelopes to models and
throw typed exceptions (
ApiException(code, status, message)).
3. Base patterns (shared)
3.1 Async state machine
sealed class LoadState { Initial, Loading, Success, Error(ApiException) }
Every list/detail cubit exposes LoadState + data. UI maps:
- Initial/Loading →
AppSkeleton - Error →
AppErrorState(code, message, onRetry) - Success + empty →
AppEmptyState - Success + data → content
3.2 Pagination cubit (mixin)
mixin PaginatedListMixin<T> {
int page; bool hasNext; bool isLoadingMore; List<T> items;
// loadFirst() -> emits Loading; loadMore() -> appends; pullToRefresh() -> resets
}
Contract mirrors API: page, limit (default 20, max 100), sort (-field), q.
meta from envelope: totalItems, totalPages, hasNext, hasPrevious.
3.3 Cache & staleness
- Repository caches last successful list per key (
sl:cache:{module}:{query}client-side). - Stale-while-revalidate: show cache instantly, refresh in background, update on success.
- TTLs: reference data (classes, subjects) 24 h; volatile lists (attendance today) 5 min; detail views no client cache (server caches).
RefreshIndicatoralways bypasses cache.
3.4 Realtime
- WS gateway (
/wsviaWsModule) deliversnotification.new,message.new,announcement.published,attendance.changed,invoice.updatedevents to user rooms. - Cubits subscribe via repository
subscribe(channel)→ pushes events into state. - On reconnect: resubscribe + pull-to-refresh equivalent (re-fetch current screen).
- App in background: WS paused; notifications surface via push; on resume → re-fetch.
3.5 Optimistic updates
- Mutations that are safe (toggles, mark-read, status changes) apply locally first,
then call API; on error: rollback +
AppSnackbar(error); on success: reconcile with server payload. - Mutations with side effects (payments, publishing results) are never optimistic.
3.6 Auth state
AuthCubit:unauthenticated → authenticating → authenticated(user, tenant) → refreshing → sessionExpired.- Token refresh on 401 (
diointerceptor, single-flight); on refresh failure →sessionExpired→ login screen with reason snackbar. - Permission changes trigger route rebuild.
3.7 Connectivity state
ConnectivityCubit(internet_connection_checker or connectivity_plus):online/offline.- Offline: banner (
AppOfflineBanner), reads from cache, writes queued (only where module docs define offline write queues — attendance bulk, homework drafts), auto-flush on reconnect with idempotency keys.
4. Event/state conventions
Load{Entity},Refresh{Entity},LoadMore,Retry,ChangeFilter,Submit,Clearnaming.- One Cubit per screen; shared selectors for cross-screen data (current user, current academic year, feature flags).
FeatureFlagsCubitgates UI per tenant (biometric, SMS, WhatsApp channels).
5. Error handling (client)
| Server code | Client behaviour |
|---|---|
UNAUTHENTICATED (401) | Refresh once; fail → session expiry flow |
PERMISSION_DENIED (403) | 403 screen or hide action; snackbar on inline actions |
RESOURCE_NOT_FOUND (404) | Empty-state with "not found" copy |
VALIDATION_ERROR (400) | Field errors mapped to form fields |
DUPLICATE_RESOURCE (409) | Inline conflict message; suggest search/refresh |
BUSINESS_RULE_VIOLATION (422) | Message in context (dialog/banner) |
RATE_LIMITED (429) | "Too many requests — retry in Ns" + backoff |
INTERNAL_SERVER_ERROR (5xx) | Generic + requestId; retry offered |
6. Testing hooks
- Cubits are pure-Dart, unit-testable with mocked repositories.
AppStateObserverlogs transitions (dev only).- Every cubit has a widget-test pair driving
AppSkeleton/AppErrorState/AppEmptyStatepermutations.
07 — API Conventions (Shared)
- 1. Base
- 2. Success envelope (exact)
- 3. Error envelope (exact)
- 4. Rate limits (client-relevant)
- 5. Pagination & filtering conventions
- 6. Multi-tenancy & auth
- 7. Caching (client + server)
- 8. Realtime (WebSocket)
- 9. Optimistic UI & idempotency (client contract)
- 10. Offline strategy
- 11. Security (client obligations)
- 12. Versioning
Exact wire contract of the StudyLyon API, derived from source (
ResponseEnvelopeInterceptor,HttpExceptionFilter,PaginationQueryDto, rate-limit guards,WsModule). Module docs 12_API_Mapping.md map screens to endpoints using only these shapes.
1. Base
- Base URL:
https://api.<domain>/api/v1(version prefixv1; seeversion.constants.ts). - Content-Type:
application/json; multipart for file uploads. - Auth:
Authorization: Bearer <accessToken>; refresh viaPOST /auth/refresh. - Request ID: server echoes
x-request-id; client sends its own UUID when absent. - API keys (
X-API-Key) for M2M — not used by the client app.
2. Success envelope (exact)
{
"success": true,
"message": "OK",
"data": { },
"meta": { },
"timestamp": "2026-08-02T10:00:00.000Z",
"requestId": "..."
}
- Paginated endpoints return
data= array,meta={ page, limit, totalItems, totalPages, hasNext, hasPrevious }. - Non-paginated endpoints omit
meta.
3. Error envelope (exact)
{
"success": false,
"message": "Validation failed.",
"error": { "code": "VALIDATION_ERROR", "details": [{ "field": "email", "message": "..." }] },
"timestamp": "...",
"requestId": "..."
}
| HTTP | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | Invalid input (details = per-field) |
| 401 | UNAUTHENTICATED | Missing/expired/invalid token |
| 403 | PERMISSION_DENIED | Authed but not allowed |
| 404 | RESOURCE_NOT_FOUND | Missing resource (also for cross-tenant IDs — do not leak existence) |
| 409 | DUPLICATE_RESOURCE | Unique constraint hit |
| 422 | BUSINESS_RULE_VIOLATION | Business rule refused |
| 429 | RATE_LIMITED | Rate limit exceeded |
| 5xx | INTERNAL_SERVER_ERROR | Generic; never expose internals |
4. Rate limits (client-relevant)
| Tier | Limit | Notes |
|---|---|---|
| auth | 10/min | login, register, reset — client shows countdown, no auto-retry |
| api | 100/min | normal app usage; client backoff on 429 |
| admin | 500/min | admin endpoints |
5. Pagination & filtering conventions
- Query:
page(1-based),limit(1–100, default 20),sort(fieldor-field),q(global search term where the controller supports it). - Filters are controller-specific query params (documented per module).
6. Multi-tenancy & auth
- Every request carries JWT with
tenantIdclaim; server derives tenant from token — client never sendstenantIdin the body. - Guard:
Public()decorator exempts auth endpoints only. - Cross-tenant access → 403/404; client treats as permission/not-found.
7. Caching (client + server)
- Server: Redis cache
sl:{tenantId}:{key}, TTL per module; dashboard KPIs cached. - Client: last-good cache + stale-while-revalidate (
06_State_Management.md §3.3). - No
Cache-Controlguarantees from API — client caching is advisory.
8. Realtime (WebSocket)
- Gateway: WS upgrade with
Authorization: Bearer <token>(or first-message auth). - Channels: per-user room
user:{userId}; topicsnotification.new,message.new,announcement.published,attendance.changed,invoice.updated,results.published. - Message shape:
{ type: topic, tenantId, data, timestamp }. - Disconnect → buffered in-app notifications delivered via REST on next fetch.
9. Optimistic UI & idempotency (client contract)
- Safe mutations:
PATCH read, status toggles → optimistic + rollback. - Write-once ops (payment, publish, submit) → show server result, no local write.
- Client sends
Idempotency-Keyheader (UUID) on critical POSTs where the module API supports dedup; retry-safe by design otherwise.
10. Offline strategy
- Reads: last-good cache, offline banner,
Retryon every failure. - Writes: only module-defined queues (attendance bulk, homework draft) with local persistence + flush on reconnect + idempotency keys.
- Files: uploads resume-capable (chunked) only where backend supports it — otherwise queue with retry + DLQ visibility.
11. Security (client obligations)
- Tokens in secure storage (Keychain/Keystore), never logs.
- Biometric gate for sensitive screens (payments, admin settings) — forward-looking (backend 2FA exists; client-side gate is app-level).
- TLS only; pinning per org policy.
- All error UI derived from server codes — never render raw server messages that may contain internals (use i18n keys, fall back to message for 4xx business text only).
12. Versioning
v1current; breaking changes → new version prefix; client negotiates viaAccept: application/vnd.studylyon.v1+jsonif backend adds it (flag: not in code yet).
08 — Interaction & Motion (Shared)
- 1. Touch gestures (phone/tablet)
- 2. Desktop/web (mouse + keyboard)
- 3. Haptics (mobile)
- 4. Motion language
- 5. Micro-interaction catalogue
- 6. Frustration controls
Global interaction conventions and motion language. Module docs 10_Interaction_Specification.md specialize per module.
1. Touch gestures (phone/tablet)
| Gesture | Behaviour | Where |
|---|---|---|
| Tap | Primary action, ripple m-fast | Everywhere |
| Long-press | Context menu (AppMenu), reorder, multi-select entry | List rows, cards, attachments |
| Double-tap | Zoom on images/PDF; nothing else | Media previews |
| Swipe left/right | Row actions (mark read, delete draft, archive) | Lists with Dismissible-style actions (keep ≤ 2) |
| Pull down | Refresh | All scrollables |
| Pinch | Zoom | Media, charts (tablet) |
| Drag | Reorder (timetable slots, dashboard widgets), image preview dismiss | Module-specific |
| Hardware back / back gesture | Pop; on root tab → exit app (Android) or previous shell branch | All |
| Edge swipe | Drawer on phone | App shell |
2. Desktop/web (mouse + keyboard)
| Input | Behaviour |
|---|---|
| Hover | m-fast surface tint, tooltip after 500 ms |
| Click | Tap equivalent |
| Right-click | Context menu (same as long-press) |
| Wheel | Scroll; Ctrl+wheel zoom on media |
| ESC | Close dialog/sheet/menu/search; cancel selection |
| Tab / Shift+Tab | Focus traversal (logical order), visible focus ring primary |
| Enter | Submit focused form; Space toggles focused control |
| Ctrl+K | Global search |
| Ctrl/Cmd+Enter | Submit from text areas |
| Arrows | List navigation in menus/dropdowns |
3. Haptics (mobile)
| Event | Haptic |
|---|---|
| Success | HapticFeedback.lightImpact() |
| Error | HapticFeedback.mediumImpact() |
| Long-press menu | HapticFeedback.selectionClick() |
| Pull-to-refresh completion | lightImpact() |
| Destructive confirm | heavyImpact() |
| Rule | Haptics off when system setting disabled; no haptics on web/desktop |
4. Motion language
- Enter: new screen slides forward (
m-base); content fades + slight rise (m-slow). - Exit: reverse slide; dialogs/sheets scale+fade in, slide down on dismiss.
- Hero: shared-element flight for list→detail (avatar→profile header, thumbnail→
attachment preview)
m-slow. - State change: color/size transitions
m-fast; icon morph viaAnimatedSwitcher. - Skeleton→content: cross-fade
m-slow, no pop. - Reduced motion (accessibility setting): all transitions become fades ≤
m-instant; no parallax, no stagger, no shimmer loop. - Density: never animate layout shifts; use
AnimatedSizefor expanding sections.
5. Micro-interaction catalogue
| Interaction | Detail |
|---|---|
| Row tap | Ink ripple + 2% surface darken; 40 ms press latency feedback |
| FAB appear | Scale-in m-entrance; hide on scroll-down |
| Tab switch | Indicator slide m-base |
| Badge update | Scale pop on unread count change |
| Pull-to-refresh | Spinner primary, m-slow; on success + lightImpact |
| Submit button | Loading spinner replaces label; success → brief check + snackbar |
| Infinite scroll load | Bottom spinner fade-in m-instant |
6. Frustration controls
- Every interactive element responds within 100 ms of press (press state visible).
- Network-bound actions always show pending state — never a silent wait.
- Repeated taps on submit are disabled while pending (anti-double-submit).
m-fastripple only — no full-screen flashes.
09 — Accessibility Baseline (Shared)
- 1. Standards
- 2. Contrast (light & dark)
- 3. Touch & pointer targets
- 4. Dynamic type / text scaling
- 5. Semantics (TalkBack / VoiceOver)
- 6. Focus (keyboard, desktop, TV)
- 7. Screen reader labels (canonical patterns)
- 8. Motion & vestibular safety
- 9. Color-blind safe
- 10. Forms
- 11. Headings & landmarks
- 12. Testing checklist (per screen)
The mandatory accessibility standard every screen must meet. Module docs reference this; QA verifies per
14_QA_Checklist.mdand this file's tables.
1. Standards
- WCAG 2.1 AA (perceivable, operable, understandable, robust).
- Material 3 accessibility guidance.
- Android TalkBack + iOS VoiceOver certified flows; Windows NVDA (web/desktop).
2. Contrast (light & dark)
| Use | Minimum |
|---|---|
| Body text | 4.5:1 |
| Large text (≥ 18 pt / 14 pt bold) | 3:1 |
| UI components & iconography (required to identify) | 3:1 |
| Disabled content | exempt but ≥ 3:1 vs background |
| Focus indicators | 3:1 vs adjacent |
3. Touch & pointer targets
- ≥ 48 × 48 dp tappable; 44 dp allowed for inline dense controls.
- ≥ 8 dp gap between adjacent targets.
- Desktop hover targets ≥ 32 px.
4. Dynamic type / text scaling
- All layouts must reflow up to 200% text scale without clipping (test at system font scales: default, 1.5×, 2×).
maxLines/ellipsis only for labels, never for actionable content.- No fixed-height containers for body text.
5. Semantics (TalkBack / VoiceOver)
- Every interactive element: label, role, state (checked/toggled/expanded), hint when needed.
- Decorative icons:
excludeFromSemantics; icon-with-text buttons: single combined label. - Screen readers announce: validation errors on focus, loading states, list position ("item 3 of 12"), empty-state messages.
- Live regions for: search results count, snackbar actions (UNDO), realtime updates (notification count), infinite-scroll load.
- Order: follows visual reading order (focus traversal verified per screen).
6. Focus (keyboard, desktop, TV)
- Visible focus ring (
primary, 2 dp, offset 2) on all interactive elements. - Logical
FocusTraversalGroupper screen; modal dialogs trap focus. - ESC exits modals; focus returns to trigger.
7. Screen reader labels (canonical patterns)
| Element | Label pattern |
|---|---|
| NavigationBar destination | Label + "selected/unselected" |
| FAB | Verb + noun: "Add student" |
| Chart | "Attendance trend, October: 92% present" (summary text) |
| Table | Column headers announced; row summary |
| Avatar | Person's name; "photo" if image |
| Badge | "3 unread notifications" |
| Progress | "Loading fees" or percent |
| Error banner | "Error: could not load fees. Retry" |
| Offline banner | "You are offline. Showing saved data" |
8. Motion & vestibular safety
- Reduced motion respected everywhere (see 08_Interaction_&_Motion.md §4).
- No flashing content > 3 flashes/sec.
- Autoplay only with user intent; no parallax.
9. Color-blind safe
- Never color-only status (attendance present/absent, fee paid/overdue): pair with icon
- label (✓ present, ⚠ overdue).
- Approved combos:
primary/onPrimary,error/onError,success/onSuccesswith icons.
10. Forms
- Labels visible (not placeholder-only), associated with inputs.
- Errors: text + icon, announced, focus moved to first invalid field on submit.
- Autocomplete/autofill hints on auth, profile, address fields.
11. Headings & landmarks
- One
headlineMediumper screen; logical h-hierarchy; sections useSemantics(headers:); desktop landmark navigation (skip links on web).
12. Testing checklist (per screen)
- TalkBack walkthrough: full flow without sight
- VoiceOver walkthrough: full flow without sight
- Keyboard-only desktop flow
- Text scale 2×: no clipping/overlap
- Contrast audit (light + dark)
- Focus order + visible focus ring
- Live regions announce async updates
- No color-only statuses
10 — QA Baseline (Shared)
- 1. Performance budgets
- 2. Offline matrix
- 3. Security checklist
- 4. Device matrix (P0)
- 5. Localization
- 6. Dark/light mode
- 7. Animation QA
- 8. Analytics events (shared)
- 9. Test pyramid (client)
- 10. Release gates
Cross-module quality gates every screen/module must pass. Module
14_QA_Checklist.mdfiles add module-specific items to this baseline.
1. Performance budgets
| Metric | Budget |
|---|---|
| App cold start → login | < 2.5 s mid-range device |
| Screen load (list, cached) | < 300 ms to first frame |
| Screen load (list, network) | skeleton ≤ 200 ms, content ≤ 2 s |
| Scroll jank | 0 dropped frames in profiled scroll (60 fps) |
| Image list scroll | < 8 ms frame build cost |
| APK/IPA size | debug < 100 MB, release < 40 MB |
| Memory | no growth across 20 min session |
| Network | no duplicate parallel requests for same resource |
2. Offline matrix
| Scenario | Expected |
|---|---|
| Airplane mode → open app | Cached data renders + offline banner |
| Kill app offline, relaunch | Cached data + banner |
| Offline → attempt write | Blocked with guidance (unless module defines offline queue) |
| Offline → background → online | Auto-flush queue, snackbar "Synced N items" |
| Slow network (3G throttle) | Skeletons, no ANR, retry works |
| Network loss mid-upload | Upload shows pending/retry; no silent corruption |
| Network loss mid-payment | Never optimistic; result from server; guidance "verify before retry" |
3. Security checklist
- No tokens/logs in crash reports, analytics, or screenshots
- Secure storage used for tokens (Keychain/Keystore)
- No secret in client binary (keys, URLs with secrets)
- Biometric/screen-lock gate on sensitive screens where defined
- All requests HTTPS; TLS 1.2+
- Server 5xx internals never surfaced in UI
- Idempotency headers on critical POSTs
- Session expiry → graceful re-login, state preserved where safe
4. Device matrix (P0)
| Class | Devices |
|---|---|
| Phone | Pixel 8, Galaxy S23, iPhone 14/SE, 2× mid-range Android |
| Tablet | iPad 10, Galaxy Tab S8 (portrait + landscape) |
| Desktop/web | Chrome, Edge, Safari, Firefox ≥ 1280 px |
| Foldable | Pixel Fold (cover + unfolded) |
| OS | Android 10–15, iOS 16–18, Windows 11, macOS 14 |
5. Localization
- i18n via
flutter_localizations; backend i18n module exists (i18ncontroller) — client strings client-side; server strings rendered through i18n keys where provided. - RTL readiness: layout direction follows locale; no hardcoded alignment.
- Date/time/number formatting via
Intllocale. - Text scale & string length: German/French/Hindi/Spanish smoke strings must not break layouts (flag: actual supported locales = org config; base English + platform).
6. Dark/light mode
- Full visual QA pass in both modes on every screen.
- No hardcoded colors; tokens only (enforced by code review + golden tests).
7. Animation QA
- All transitions
m-*tokens; reduced-motion respected. - No dropped frames during hero/transitions (profile 120 fps).
- Haptics only where defined; none on desktop.
8. Analytics events (shared)
All module events follow: {module}.{screen}.{action} (e.g., students.list.search).
Common events: app.launch, auth.login.success|failure, screen.view, search.perform,
offline.enter|exit, error.viewed(code), sync.flush. Proposal flag: analytics SDK
not selected; events are contract for later wiring.
9. Test pyramid (client)
| Layer | Coverage target |
|---|---|
| Unit (cubits, validators, formatters) | ≥ 80% logic |
| Widget tests | Every screen: 3 states (loading/error/success+empty) |
| Golden tests | Shared components + every screen (light/dark, 3 sizes) |
| Integration | Critical journeys (login, mark attendance, pay invoice) |
| E2E (device cloud) | P0 flows per release |
10. Release gates
- Typecheck + lint + full test suite green
- Perf budgets met (profiled)
- A11y checklist per changed screen
- Offline matrix pass
- Cross-tenant & permission scenarios (403/404) pass
- No console errors / crash-free session rate ≥ 99.9%
11 — Flutter App Architecture (Shared)
- 1. Project & dependencies
- 2. DI (get_it)
- 3. Module folder structure (per module)
- 4. DTO → model mapping
- 5. Networking
- 6. Router (go_router)
- 7. Theme
- 8. Extensions (shared)
- 9. Localization
- 10. Analytics
- 11. Storage & security
- 12. Testing strategy
- 13. Performance
- 14. CI/CD (proposal)
How the Flutter client is built. Module docs
15_Flutter_Implementation_Guide.mdextend this with module-specific structure. Forward-looking spec (no client repo exists yet).
1. Project & dependencies
studylyon_app/ (Flutter 3.x, Dart 3)
├── lib/
│ ├── main.dart
│ ├── app.dart
│ ├── core/ # di, router, theme, dio, storage, i18n, analytics, ws
│ ├── features/
│ │ └── <module>/ # data, domain, presentation (see §3)
│ └── shared/ # shared widgets, mixins, extensions
└── test/ # unit, widget, golden, integration
Recommended deps (verify licenses): flutter_bloc, dio, go_router, get_it,
hive (cache) or shared_preferences, intl, flutter_localizations,
connectivity_plus (or internet_connection_checker), fl_chart, mobile_scanner
(flagged), flutter_markdown, cached_network_image, secure_storage, uuid.
2. DI (get_it)
AppDio(authenticated client),AuthRepository,TenantRepository, module repositories, cubits (lazy factory). No singletons holding per-screen state.
3. Module folder structure (per module)
features/<module>/
├── data/
│ ├── dto/<entity>_dto.dart # envelope-payload mappers
│ ├── models/<entity>.dart # domain models
│ └── repositories/<entity>_repository.dart
├── domain/
│ └── (optional) use cases when > 1 repo interaction
└── presentation/
├── cubit/<screen>_cubit.dart
├── pages/<screen>_page.dart
└── widgets/<screen-specific widgets>
4. DTO → model mapping
- API payload →
*Dto.fromJson(validate types) → domain model (normalized ids, enums, datesDateTime, money in minor unitsint). - Serialization with
json_serializable(build_runner) or hand-writtenfromJson(small modules) — pick one, prefer codegen for consistency. - Never pass DTOs into widgets; widgets consume models.
5. Networking
AppDio: base URL, bearer interceptor (auto-attach token), refresh interceptor (single-flight refresh, queue requests), error interceptor mapping envelope → typedApiException(code/status/fieldDetails), idempotency header helper, requestId header, timeout 15 s (uploads 120 s).- WS:
WsClient(socket.io-client or dartweb_socket_channelper backend gateway — verify backend protocol; flagged assumption) with auto-reconnect + backoff.
6. Router (go_router)
AppRouter:GoRouterwithStatefulShellRoute.indexedStackbranches per top-level destination; route guards:authGuard,permissionGuard('x.y'),tenantGuard.- Deep links:
Uriroute table in module docs; push-notification tap → navigate by path. - 403/404/500 error screens registered globally.
7. Theme
AppTheme.light()/dark()from02_Design_Tokens.md; org branding override;MaterialApp(theme, darkTheme, themeMode, locale, localizationsDelegates).
8. Extensions (shared)
contexthelpers:spacing,colorSchemeshortcuts,showAppSnackbar(type, message, action).String:toDate,capitalize,maskedEmail,initials.int:toMoney(currency),toOrdinal.DateTime:toDisplayDate(locale),startOfDay,isSameDay,weekdayLabel.List:groupBy,chunk.
9. Localization
.arbfiles:app_en.arb,app_fr.arb, … (start: en; add per org demand).AppLocalizationsgenerated (intl_utils); all user-facing strings via keys — including server messages mapping (error code → key, fallback to server message only for business 4xx text).
10. Analytics
AnalyticsServiceinterface (firebase_analytics recommended; swappable); events per10_QA_Baseline.md §8; consent-gated; no PII beyond necessity.
11. Storage & security
flutter_secure_storage: access/refresh tokens, session meta.shared_preferences: theme, locale, onboarding flags, cache of reference data.- Hive (if adopted): paginated list caches per module key
{tenant}:{module}:{query}.
12. Testing strategy
- Unit: cubits (mock repos), validators, formatters, mappers.
- Widget: state-machine tests per screen; golden per shared component + screen (light/dark, 3 sizes); fonts bundled in test assets for stable goldens.
- Integration:
integration_testjourneys: login → home; attendance mark flow; invoice pay flow; offline banner behavior. - E2E: device-cloud smoke per release (P0 journeys).
- Run:
flutter analyze,flutter test,flutter test integration_test -d <device>.
13. Performance
constconstructors everywhere;ListView.buildermandatory for lists;RepaintBoundaryon charts/media; image cachecached_network_imagewith resize; shader compilation jank → target Android 15+Impeller; defer heavy init off first frame; isolate for CSV parsing (bulk import preview).- Profile every release on mid-range device against
10_QA_Baseline.md §1.
14. CI/CD (proposal)
- GitHub Actions: analyze → unit → widget → golden → integration (Android emulator + Chrome) → build signed AAB/IPA; screenshots for QA on PRs (golden diffs).
12 — Assumptions & Open Questions (Shared)
- A. Product-level
- B. API / backend
- C. Product decisions deferred (no backend signal)
- D. Design system
- E. Governance
Every cross-cutting uncertainty affecting the whole design-docs set. Module docs list their own module-specific items; this file is the global ledger.
A. Product-level
| # | Item | Status | Impact |
|---|---|---|---|
| A1 | PRD conflict: native mobile apps are out of Phase 1 scope (PRODUCT_REQUIREMENTS_DOCUMENT.md:144); roadmap Phase 3 = read-only companion. These docs specify a full client. | Owner decision needed — proceeding per user instruction: full forward-looking specs | All Flutter docs |
| A2 | Web-first UI exists only as roadmap; no web client spec | Assumed: same design system, responsive shell (05_Global_Information_Architecture.md) | All |
| A3 | No brand assets, logo, or org branding defaults beyond organization.branding schema field | Assumed: token defaults in 02_Design_Tokens.md; org override hooks only | Theme, onboarding |
| A4 | Analytics SDK not chosen | Contract defined (10_QA_Baseline.md §8), SDK open | All analytics events |
| A5 | Supported locales beyond English unspecified (backend i18n module exists) | Assumed: en + platform locales; org-driven additions | Localization |
B. API / backend
| # | Item | Status | Impact |
|---|---|---|---|
| B1 | Endpoints in docs/IMPLEMENTATION_PLAN.md not yet in code (full communication/leave/CRM-admission/timetable-slot APIs) | Flagged (planned) in every doc where used | Those modules |
| B2 | WebSocket handshake/protocol not implemented (WsModule gateway exists) | Assumed standard WS + bearer; verify before build | Realtime everywhere |
| B3 | No push-device registry/DeviceToken endpoint | Forward-looking; docs mark push entry points as planned | Notifications, attendance |
| B4 | No QR-code backend support (no QR endpoints) | QR entry points marked forward-looking only | Biometric, transport, library |
| B5 | Server cache TTLs not enumerable from code | Client cache TTLs proposed per module (06 §3.3) | All lists |
| B6 | Idempotency-Key header support not confirmed per endpoint | Client contract defined; verify per module | Writes |
| B7 | File upload resume (chunked) not implemented | Uploads queued + retried, no resume | Files, homework |
| B8 | Payments integration: gateway status per tenant unknown (manual reconciliation per PRD §8) | Client shows "payment recorded/pending" from server state only | Payments, fees |
C. Product decisions deferred (no backend signal)
| # | Item | Who decides |
|---|---|---|
| C1 | Exact navigation order/destinations per role (proposed in 05 §2) | Product owner |
| C2 | Which lists are infinite-scroll vs paginated | Per-module design owner |
| C3 | Offline write queues beyond attendance/homework drafts | Product owner |
| C4 | Biometric client gate (fingerprint for payments/admin) | Security owner |
| C5 | Dashboard widget set + customization depth | Product owner |
| C6 | Notification channel defaults per role (email/push/in-app/WhatsApp) | Tenant config (backend notification.read perms) |
D. Design system
| # | Item | Status |
|---|---|---|
| D1 | 02_Design_Tokens.md updated to designer-provided token export (v1) | Signed off except rows marked TBD by designer (extrapolated surfaces §1.2, dark chromatic roles §1.4, decimal spacing gaps §3.2, radius 4/16/28 §4, elevation §5) — confirm before ship |
| D2 | Iconography pack unselected | Material Symbols assumed; no icon tokens in export |
| D3 | Flutter deps list unverified for licensing | Verify before adopting (fl_chart, mobile_scanner, flutter_markdown) |
| D4 | Typeface resolved | Inter (UI) + JetBrains Mono (data) — designer-provided; org font support planned |
| D5 | Dark chromatic roles (primary/secondary/tertiary/error/success/warning) absent from export | TBD by designer; working proposal = M3 tonal derivation from light roles (dark primary ≈ inversePrimary #B7C4FF) |
| D6 | No elevation/shadow tokens in export | dp scale (e-0…e-5) + shadow recipes proposed from Black + opacity.10; TBD by designer |
E. Governance
- When any of the above is resolved, the owning doc(s) must be updated; module docs should not contradict this ledger.
- Any doc that invents a feature absent from source violates policy — reviewers check derivability. Items above are the only sanctioned extrapolations.
01 — Product Overview (Academics Module)
- 1. Purpose
- 2. Business goals
- 3. User goals
- 4. Stakeholders
- 5. Why this exists
- 6. Dependencies
- 7. Success metrics
- 8. Edge cases
- 9. Assumptions (module)
- 10. Open questions (module-level; global ledger in
00-shared/12) - 11. Glossary (this module)
StudyLyon — multi-tenant ERP / School Management API. This package designs the Academics module client (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, indexes, business rules, and wire contracts are derived directly from
src/modules/academics/**,src/database/base.*,src/common/dto/pagination-query.dto.ts,studylyon-blueprint/03-Database/*,04-Modules/*,PLAN.md, anddocs/IMPLEMENTATION_PLAN.md. No feature is invented; gaps are flagged in the Assumptions & Open Questions section.
1. Purpose
Academics is the structural backbone of StudyLyon. It defines the five reference entities every operational module hangs off:
| Entity | Collection | Meaning | Source |
|---|---|---|---|
| Academic Year | academic_years | A session (e.g. "2026–2027") with dates, status, isCurrent flag | schemas/academic-year.schema.ts:13 |
| Grade | grades | A year level ("Grade 1", "Grade 10"), optional per-year, displayOrder | schemas/grade.schema.ts:7 |
| Section | sections | A division of a grade ("A", "B", "C") | schemas/section.schema.ts:7 |
| Class | classes | The concrete teaching unit: year + grade + section (e.g. "Grade 10 - A") | schemas/class.schema.ts:7 |
| Subject | subjects | Master catalog (code, marks configuration, credits) | schemas/subject.schema.ts:7 |
| Subject Assignment | subject_assignments | Teacher ↔ Subject ↔ Class ↔ Year binding | schemas/subject-assignment.schema.ts:7 |
1.1 Why it is the backbone
Downstream modules reference these entities by ObjectId and are meaningless without them (verified in source):
- Timetable requires
classId,subjectId,teacherId,academicYearId(timetable/schemas/timetable.schema.ts:18-40). - Attendance requires
classId(attendance/schemas/attendance.schema.ts:28-29). - Students enrollment binds to
classIdper year (blueprint04-Modules/Students.md:31). - Homework / Exams / Results / Fees consume classes and subjects as reference data
(blueprint
04-Modules/Exams.md:51,03-Database/RELATIONSHIPS.md §4, §6).
The canonical hierarchy (blueprint 03-Database/RELATIONSHIPS.md:50-61):
organizations
└─ academic_years (many, per tenant)
└─ grades (many)
└─ sections (many)
└─ classes (many) via gradeId + sectionId
├─ students (many via class_enrollments)
├─ timetables (many)
└─ homework (many)
subject_assignments binds the teaching staff into this tree
(03-Database/RELATIONSHIPS.md:84-92):
teachers (1)
└─ subject_assignments (many) via teacherId
├─ subjects (via subjectId)
└─ classes (via classId)
2. Business goals
| Goal | Measure | Evidence |
|---|---|---|
| One-click year rollover | PATCH /academic-years/:id/set-current toggles exactly one isCurrent per tenant (services/academic-year.service.ts:69-83) | blueprint rule: "Only one academic year can be active per organization" (COLLECTIONS.md:1023) |
| Zero name collisions on master data | unique-per-tenant checks: academic-year name (academic-year.service.ts:25-29), grade name (grade.service.ts:21-23), subject code + DB unique index (subject.service.ts:21-25, subject.schema.ts:38) | 409 DUPLICATE_RESOURCE |
| Ordered, predictable grade ladder | displayOrder sort default on grades (grade.service.ts:39); index {tenantId, displayOrder} (grade.schema.ts:27) | |
| Full multi-tenant isolation | every query tenant-scoped by BaseRepository.scopedFilter() (database/base.repository.ts:20-30) | |
| Downstream consistency | timetable/attendance filter by classId/academicYearId; changes here propagate to their lists (timetable.service.ts, attendance.schema.ts:63) |
3. User goals
- Organization admin: set up the year, grade ladder, sections, subjects once; flip the current year at rollover; see the whole structure at a glance.
- Academic coordinator: build grades → sections → classes, assign subjects and teachers per class per year, catch conflicts before timetabling starts.
- Teacher: see which classes/subjects they teach in the current year; use class lists to find their sections.
- Student / parent (read-only): browse the current year's structure — grade, section, class name, subject list — to understand where they sit.
4. Stakeholders
Platform operator, institution admins, academic coordinators, teachers, students, parents, timetable/attendance/homework/exam feature owners (consumers of this reference data), QA + design + engineering.
5. Why this exists
Every school runs on a fixed academic calendar and ladder. StudyLyon is multi-tenant
with heterogeneous school structures (per blueprint COLLECTIONS.md:925-969); a
tenant-defined year/grade/section/class tree is the precondition for enrollment,
attendance, timetable, exams, and fees. Without this module those modules cannot
target a student's actual teaching unit.
6. Dependencies
| Dependency | Role | Source |
|---|---|---|
| Auth (JWT) | every endpoint guarded by JwtAuthGuard | academics.module.ts:4,46; all controllers @UseGuards(JwtAuthGuard) |
| RBAC permissions | gap: no academics.* permission exists in ALL_PERMISSIONS — only JWT presence is enforced today | rbac/permissions.constants.ts:1-97 (see OQ-1) |
TenantContextService | tenant scoping + platform-admin bypass | repositories/*.ts, database/base.repository.ts:20-30 |
| Mongo indexes | {tenantId, code} unique on subjects; per-entity tenant+ref indexes | subject.schema.ts:38, class.schema.ts:38-40 |
| Timetable / Attendance / Students / Homework / Exams | consumers of class/subject/year references | timetable/schemas/timetable.schema.ts:18-40, attendance/schemas/attendance.schema.ts:28-29 |
7. Success metrics
- Year setup (year → grade → section → class → subject → assignment) completed in < 15 min for a 12-grade school on a mid-range device.
- Zero duplicate master records per tenant (server 409s surfaced correctly, never silently overwritten).
- Hierarchy browse renders 12 grades × 4 sections × 40 classes with no jank
(long-list budgets per
00-shared/10 §1). - Conflict surface (duplicate class name, teacher double-booked) visible before
timetable creation — flagged
(planned)since the API has no conflict endpoint today (OQ-4).
8. Edge cases
- Duplicate academic-year name → 409 "Academic year "X" already exists."
(
academic-year.service.ts:27-28); duplicate grade name → 409 (grade.service.ts:23); duplicate subject code → 409 + unique index fallback (subject.service.ts:23-24,subject.schema.ts:38). - Classes/sections/assignments have no duplicate guard in code — duplicate
(gradeId, sectionId, name)classes and duplicate assignments are possible (OQ-4):class.service.ts:16-18,subject-assignment.service.ts:13-17. - Soft delete everywhere (
BaseRepository.softDelete,base.schema.ts:20-27) — lists hide deleted docs automatically; no cascade — deleting a grade leaves orphan sections/classes behind (OQ-3,RELATIONSHIPS.md:134-138). - Grade/section/class
statusis a free string (schema default'active'), not an enum — client must treat any non-'active'value as inactive (grade.schema.ts:21-22,section.schema.ts:24-25,class.schema.ts:33-34). set-currentun-flags everyisCurrentthen flags the target and setsstatus: active(academic-year.service.ts:72-79); the target may have hadstatus: upcomingorarchived.- Grade
academicYearIdis optional in both schema and DTO — grades can exist outside any year (grade.schema.ts:9-10,create-grade.dto.ts:5-8); classacademicYearIdis required (class.schema.ts:9-10). - Subject marks are independent ints — no cross-field validation
(theory+practical vs maximum, passing ≤ maximum) in DTO or service
(
create-subject.dto.ts:18-46, OQ-5). UpdateSubjectDtocannot changetheoryMarks/practicalMarks(update-subject.dto.ts:4-42).- Assignments have no
GET /subject-assignments/:idand no update endpoint — remove + recreate is the only correction path (subject-assignment.controller.ts:19-39). class.campusIdhasref: 'Class'in the schema (likely intendedCampus) (class.schema.ts:12-13) androomIdis a free string, not aRoomref (class.schema.ts:30-31) — client renders these as opaque IDs/strings (OQ-6).- Academic years list default sort
createdAt: -1(newest first) (academic-year.service.ts:47); grades defaultdisplayOrder: 1(grade.service.ts:39); classes/sections/subjects/assignments are unsorted (insertion order) (class.service.ts:44-48,section.service.ts:41-48,subject.service.ts:35-47).
9. Assumptions (module)
- Mobile client is forward-looking: the backend is complete; this package is the UI-side spec against real endpoints.
- PRD / roadmap context: native mobile apps are out of Phase 1 scope
(
PRODUCT_REQUIREMENTS_DOCUMENT.md:144); roadmap Phase 3 = read-only companion — these docs still spec the full client per global decision A1 (00-shared/12 A1). Academics admin/coordinator editing is speced for tablet/desktop-first; phone gets read + quick actions. - The coaching-extension plan (
docs/IMPLEMENTATION_PLAN.md §6.2.1: sections → batches,subjectCategory,classType,batchIdfields) is not in code; all of it is marked(planned)and never shown as implemented. - The wire contract follows the fixed envelope; academics endpoints return raw
Mongoose documents inside
data(no field whitelisting/renaming at the API layer) — the client maps_id,__v, timestamps explicitly (base.schema.ts:7-35). - No pagination helper exists on assignments/
by-class/by-teacherlookups — they return bare arrays (subject-assignment.service.ts:19-31).
10. Open questions (module-level; global ledger in 00-shared/12)
| # | Item | Impact |
|---|---|---|
| OQ-1 | No academics.* permissions in ALL_PERMISSIONS (permissions.constants.ts:1-97) and no RbacGuard on any academics controller — any authenticated user (student/parent) can create/update/delete structure. When are perms + guard added? | Screen gating, hidden CTAs, client permission map |
| OQ-2 | No tenant-level unique constraint on academic-year name / grade name (service check only, race-prone); subjects have a DB unique index. Add compound unique indexes? | 409 UX, concurrency |
| OQ-3 | Deleting a grade/section/class is a bare soft-delete with no cascade and no integrity check — orphan sections/classes/assignments/attendance refs are possible. Backend cascade job (planned)? | Delete confirm copy, orphan handling |
| OQ-4 | No duplicate-class / duplicate-assignment validation; no conflict endpoint for teacher double-booking or class-name reuse per year. Front-end only, or backend validation? | Conflict UI, error copy |
| OQ-5 | Subject marks fields have no cross-field validation (theory+practical ≤ maximum, passing ≤ maximum); update cannot edit theory/practical marks. Backend fix planned? | Form validation messaging |
| OQ-6 | class.campusId ref points at Class (typo for Campus?) and roomId is a free string — no campus/room modules exist yet in code. Resolve before linking campus/room pickers. | Campus/room pickers (planned) |
| OQ-7 | PaginationQueryDto.q (global search) is parsed but never used by any academics service (services filter {} only) — server-side q search (planned). | Client search UX |
11. Glossary (this module)
| Term | Meaning |
|---|---|
| Academic Year | academic_years doc; status ∈ `upcoming |
| Grade | grades doc; level name + optional code + displayOrder; optional per-year binding |
| Section | sections doc; division of a grade (name A, B, …), own capacity/teacher/room |
| Class | classes doc; the teaching unit year + grade + section + name; required refs to year/grade/section |
| Subject | subjects doc; catalog entry with code (unique per tenant) and marks config (max/passing/theory/practical) |
| Subject Assignment | subject_assignments doc; teacher×subject×class×year tuple |
set-current | PATCH /academic-years/:id/set-current — promotes one year; demotes all others |
| Envelope | {success,message,data,meta?,timestamp,requestId} (00-shared/07 §2) |
02 — User Personas (Academics Module)
- P1 — Organization Admin ("Nishant")
- P2 — Academic Coordinator ("Meera")
- P3 — Teacher ("Mr. Okafor")
- P4 — Student / Parent (read-only)
- Persona → capability matrix (intended; not enforced by API today)
Personas for the Academics module, derived from blueprint
01-Product/USER_PERSONAS.mdrole model (rolesexamples: Super Admin, Organization Admin, Teacher, Parent, Student —COLLECTIONS.md:600-623) and the endpoints/semantics insrc/modules/academics/**. Permission notes reflect the actual guard state: every endpoint is JWT-authenticated but noacademics.*permission exists yet (rbac/permissions.constants.ts:1-97, OQ-1 in 01_Product_Overview.md) — so the "can do X" columns below are the intended mapping, marked accordingly.
P1 — Organization Admin ("Nishant")
| Attribute | Value |
|---|---|
| Role / context | Owner/admin of one school tenant; member with organization.* perms; sees everything (base.repository.ts:20-30 scoping) |
| Tech | Tablet (Galaxy Tab S8) + desktop web; occasional phone |
| Goals | Set up the school's academic skeleton once: create the year, the grade ladder, sections, classes, subjects; flip set-current at rollover; keep one source of truth |
| Pain points | Rebuilding structure every year manually; accidental duplicates ("Grade 1" created twice); forgetting to demote the old current year |
| Key flows | Create academic year → set current → bulk-create grades → create classes with sections; monitor statuses |
| Kill feature | PATCH /academic-years/:id/set-current — one tap demotes all others and activates the new year (academic-year.service.ts:69-83) |
| Perms (intended) | Full CRUD on all six entities (none enforced today) |
P2 — Academic Coordinator ("Meera")
| Attribute | Value |
|---|---|
| Role / context | Senior teacher or dedicated coordinator; the day-to-day owner of the structure after the admin seeds it |
| Tech | Desktop web primary, phone for spot checks |
| Goals | Wire subjects + teachers to classes per year; keep section/class lists accurate; spot gaps (a class with no subjects, a teacher with no assignments) before timetabling |
| Pain points | Assignment matrix gets large (classes × subjects × teachers); no bulk ops; removing a bad assignment requires delete + recreate (subject-assignment.controller.ts:19-39) |
| Key flows | Browse hierarchy per year → open class → view subjects+teachers → add/remove assignments; fix conflicts |
| Kill feature | GET /subject-assignments/by-class/:classId?academicYearId= — the per-class teaching roster (subject-assignment.controller.ts:24-29) |
| Perms (intended) | Read all; create/delete assignments; update class/grade metadata |
P3 — Teacher ("Mr. Okafor")
| Attribute | Value |
|---|---|
| Role / context | Class teacher of "Grade 10 - A" (via classTeacherId), subject teacher via assignments |
| Tech | Phone (Pixel 8) mostly; tablet in classroom |
| Goals | See which classes/sections/subjects they teach this year; know their class's structure; enter timetable/attendance against the right classId |
| Pain points | Years change → roster changes; needs year-scoped view, not a global dump |
| Key flows | My classes (by-teacher) → class detail → subjects; timetable for own class |
| Kill feature | GET /subject-assignments/by-teacher/:teacherId?academicYearId= — year-scoped teaching load (subject-assignment.controller.ts:30-35) |
| Perms (intended) | Read structure; no writes (structure is admin/coordinator-owned) |
P4 — Student / Parent (read-only)
| Attribute | Value |
|---|---|
| Role / context | Student profile linked to a class via class_enrollments (RELATIONSHIPS.md:63-65); parent linked to student |
| Tech | Phone; light usage |
| Goals | See "my grade, my section, my class, my subjects" for the current year; understand report/timetable references |
| Pain points | Currently no client-scoped endpoint: the API returns all classes/subjects per tenant — the client must resolve "my class" from enrollment data (Students module) then render its structure |
| Key flows | Home → Academics (read-only) → structure browse filtered to own class |
| Kill feature | Subject list of their class + academic year status (upcoming/active/archived badge) |
| Perms (intended) | Read only; server has no read-scoping filter — flag OQ-1 in 01_Product_Overview.md |
Persona → capability matrix (intended; not enforced by API today)
| Capability | Admin | Coordinator | Teacher | Student/Parent |
|---|---|---|---|---|
| CRUD academic year + set-current | ✅ | ❌ | ❌ | ❌ |
| CRUD grade / section / class | ✅ | ✅ (coordinated) | ❌ | ❌ |
| CRUD subject | ✅ | ✅ | ❌ | ❌ |
| Create/delete subject assignments | ✅ | ✅ | ❌ | ❌ |
| Read structure (all, tenant-scoped) | ✅ | ✅ | ✅ | ✅ (needs own-class filter, client-side) |
by-class / by-teacher rosters | ✅ | ✅ | ✅ | ❌ |
Every write above currently requires only a valid JWT (
academics.module.ts:46, controllers@UseGuards(JwtAuthGuard)). The matrix is the design target; enforcement is(planned)(OQ-1).
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.
04 — Information Architecture (Academics Module)
- 1. Place in the global shell
- 2. Content model (exact, from schemas)
- 3. Navigation tree (forward-looking routes)
- 4. Sibling relationships (cross-module)
- 5. Information grouping rules
Where Academics lives in the app shell (00-shared/05) and how its screens nest. Routes are forward-looking (no client exists); the data model below is exact from
src/modules/academics/**and03-Database/RELATIONSHIPS.md:50-61.
1. Place in the global shell
Top-level destination (per 00-shared/05 §2):
| Destination | Route prefix | Roles (default) |
|---|---|---|
| Academics | /academics | admin, teacher (read) — coordinator added to roles once RBAC lands (OQ-1) |
Shell: phone bottom-nav + drawer; tablet/desktop NavigationRail; master-detail at
≥ 840 dp (00-shared/04 §6).
2. Content model (exact, from schemas)
Academic Year (academic_years) name, startDate, endDate,
status: upcoming|active|archived, isCurrent: bool
└─ Grade (grades) [optional year binding] name, code?, displayOrder, status
└─ Section (sections) name, capacity=40, classTeacherId?, roomId?, status
└─ Class (classes) academicYearId*, gradeId*, sectionId*,
name, capacity=40, campusId?, classTeacherId?,
roomId?, status
Subject (subjects) — flat catalog code*, name, shortName?, credits=0,
maximumMarks=100, passingMarks=33,
theoryMarks=80, practicalMarks=20, status
Subject Assignment (subject_assignments) teacherId*, subjectId*, classId*, academicYearId*
- Hierarchy is NOT denormalized: a Class references year+grade+section IDs; the
client must join to render "Grade 10 · Section A" labels (
class.schema.ts:9-19). - Grades may float outside any year (
grade.academicYearIdoptional —grade.schema.ts:9-10); classes may not (class.schema.ts:9-10). - Subjects and assignments are year-agnostic catalogs except that assignments
carry
academicYearId(subject-assignment.schema.ts:18-19).
3. Navigation tree (forward-looking routes)
/academics Academics hub (admin/coordinator/read)
├── /academics/years Academic Year list
│ ├── /academics/years/new Create year (full-screen form)
│ └── /academics/years/:id Year detail (dates, status, classes of year)
│ └── /academics/years/:id/classes Classes of this year (by-year feed)
├── /academics/grades Grades list (displayOrder ladder)
│ ├── /academics/grades/new Create grade
│ └── /academics/grades/:id Grade detail → sections of grade
│ └── /academics/grades/:id/sections Sections list (by-grade feed)
├── /academics/classes Classes list (all)
│ ├── /academics/classes/:id Class detail — MASTER screen
│ │ ├── tabs: Overview | Subjects & teachers | Students(link)
│ │ └── /academics/classes/:id/assign Assignment matrix (add/replace/remove)
│ └── /academics/classes/new Create class (cascading pickers)
├── /academics/sections Sections list (all)
│ └── /academics/sections/:id Section detail (chips of classes)
├── /academics/subjects Subject catalog
│ ├── /academics/subjects/new Create subject (marks form)
│ └── /academics/subjects/:id Subject detail (marks config, usage)
├── /academics/explore Hierarchy explorer (tree view)
└── /academics/teachers/:teacherId Teacher roster (by-teacher assignments)
Route guards: authGuard + permissionGuard('academics.read') — the permission
string is (planned) (OQ-1); until the backend exposes it, the guard reads the
tenant role set (00-shared/05 §9).
4. Sibling relationships (cross-module)
| From here | To | Via |
|---|---|---|
| Class detail | Timetable of class | GET /timetable?classId= (timetable.controller.ts:22-29) |
| Class detail | Attendance of class | attendance module (classId filter) |
| Class detail | Students in class | Students module (class_enrollments, RELATIONSHIPS.md:63-65) |
| Teacher roster | Teacher timetable | GET /timetable?teacherId= (timetable.controller.ts:24-27) |
| Subject detail | Homework / Exams | Homework + Exams modules (subjectId refs, 04-Modules/Exams.md:51) |
5. Information grouping rules
- Year-first browsing: all admin reads default to the current year
(
isCurrent:trueresolved from the years list,academic-year.service.ts:69-83); a global year switcher (AppBar chip) re-scopes every list that supportsacademicYearId. - Flat catalogs, joined display: subjects/grades/classes list pages render joined labels (grade name, section name) computed client-side from the loaded reference cache — never from a server-side projection.
- Read vs admin surfaces: structure browse for students/parents reuses the same
pages with all CTAs hidden (permission-driven), per
02_User_Personas.md P4. - Master-detail: class detail is the hub for the assignment matrix and
downstream links (timetable/attendance/students); on tablet+ it opens in the
right pane at ≥ 840 dp (
00-shared/04 §6).
05 — Screen Inventory (Academics Module)
- 1. Academic Year List
- 2. Academic Year Detail
- 3. Grades List
- 4. Grade Detail (with sections)
- 5. Classes List
- 6. Class Detail — MASTER screen
- 7. Section Detail
- 8. Sections List
- 9. Subjects List
- 10. Subject Detail
- 11. Subject Assignment Matrix
- 12. Hierarchy Explorer
- 13. Teacher Roster (by-teacher)
- 14. Create/Edit forms (full-screen)
Every screen the Academics module needs, mapped to its exact endpoint source. All endpoints from
src/modules/academics/controllers/*. Read/write split per 02_User_Personas.md matrix (intended, RBAC(planned)— OQ-1).
1. Academic Year List
| Route | /academics/years |
| Endpoint | GET /academic-years (paginated) — academic-year.controller.ts:27-29 |
| Purpose | All years, newest first (default sort=createdAt:-1, academic-year.service.ts:47); status badges; current-year flag |
| Actions | Add year (FAB), set current (row menu), edit, delete (soft) |
| Empty | AppEmptyState: "No academic years yet — add your first" |
| Roles | Admin/coordinator full; teacher/student read |
2. Academic Year Detail
| Route | /academics/years/:id |
| Endpoint | GET /academic-years/:id (academic-year.controller.ts:30-32); classes feed GET /classes/by-year/:academicYearId (class.controller.ts:30-35) |
| Purpose | Dates, status, isCurrent; inline list of the year's classes |
| Actions | set-current, edit dates/status, delete |
3. Grades List
| Route | /academics/grades |
| Endpoint | GET /grades (paginated) — grade.controller.ts:27-29; default displayOrder:1 (grade.service.ts:39) |
| Purpose | The tenant's ladder (Grade 1…12) in defined order; optional code (G1), per-grade status (free string, grade.schema.ts:21-22) |
| Actions | Add grade, edit, delete, reorder via displayOrder |
4. Grade Detail (with sections)
| Route | /academics/grades/:id |
| Endpoint | GET /grades/:id (grade.controller.ts:30-32); sections GET /sections/by-grade/:gradeId (section.controller.ts:30-35) |
| Purpose | Grade metadata + its sections (chips) + classes linking it (client-joined) |
| Actions | Edit grade; add section; jump to classes of grade |
5. Classes List
| Route | /academics/classes (optional year filter via year switcher) |
| Endpoint | GET /classes (class.controller.ts:27-29) or GET /classes/by-year/:academicYearId (class.controller.ts:30-35) |
| Purpose | All classes; each row shows joined grade + section chips, capacity, status |
| Actions | Add class (cascading pickers), edit, delete |
6. Class Detail — MASTER screen
| Route | /academics/classes/:id |
| Endpoint | GET /classes/:id (class.controller.ts:36-38); roster GET /subject-assignments/by-class/:classId?academicYearId= (subject-assignment.controller.ts:24-29) |
| Tabs | Overview (meta + chips) · Subjects & teachers (assignment matrix rows) · Students (link to Students module, (planned) read-only count) |
| Actions | Edit class; add subject+teacher; replace/remove assignment; jump to timetable/attendance (consumers) |
7. Section Detail
| Route | /academics/sections/:id |
| Endpoint | GET /sections/:id (section.controller.ts:36-38); classes of section = client-joined from GET /classes |
| Purpose | Section meta (capacity, classTeacher, room) + classes using it |
| Actions | Edit section; delete |
8. Sections List
| Route | /academics/sections |
| Endpoint | GET /sections (section.controller.ts:27-29) or by-grade |
| Purpose | All sections (or of one grade) with grade label |
9. Subjects List
| Route | /academics/subjects |
| Endpoint | GET /subjects (subject.controller.ts:27-29); insertion order (subject.service.ts:35-47) |
| Purpose | Catalog: code (unique), name, shortName, marks config preview, status |
| Actions | Add subject, edit, delete |
10. Subject Detail
| Route | /academics/subjects/:id |
| Endpoint | GET /subjects/:id (subject.controller.ts:30-32) |
| Purpose | Full marks configuration (max/passing/theory/practical) + usage hint (classes assigning it — client-joined from rosters) |
| Actions | Edit (note: theory/practical not updatable — update-subject.dto.ts:4-42, OQ-5) |
11. Subject Assignment Matrix
| Route | /academics/classes/:id/assign |
| Endpoint | roster by-class; create POST /subject-assignments (subject-assignment.controller.ts:21-23); delete DELETE /subject-assignments/:id (:36-38) |
| Purpose | Full-screen matrix: rows = assigned (subject, teacher); add = subject picker → teacher picker |
| Notes | No update endpoint → "Replace" = delete+create; duplicates possible server-side (OQ-4) |
12. Hierarchy Explorer
| Route | /academics/explore |
| Endpoint | composition of GET /academic-years, /grades, /sections/by-grade, /classes/by-year (client-side tree join; no tree endpoint exists) |
| Purpose | One-screen tree: Year → Grade → Section → Class, with subject count badges; used by coordinators for gap spotting |
| Roles | All (read); admin/coordinator get inline quick-actions |
13. Teacher Roster (by-teacher)
| Route | /academics/teachers/:teacherId |
| Endpoint | GET /subject-assignments/by-teacher/:teacherId?academicYearId= (subject-assignment.controller.ts:30-35) |
| Purpose | Teacher's teaching load per year: class + subject rows; conflict spotter (same subject in 2 classes) |
| Roles | Teacher (own, year-scoped), coordinator (any) |
14. Create/Edit forms (full-screen)
Routes /…/new and /…/:id/edit for year, grade, section, class, subject — one
screen per entity, field specs in 08_Form_Specifications.md.
Screen → endpoint matrix
| Screen | Reads | Writes |
|---|---|---|
| Year list / detail | GET /academic-years(,/:id) | POST, PATCH /:id, PATCH /:id/set-current, DELETE /:id |
| Grades list / detail | GET /grades(,/:id), GET /sections/by-grade/:gradeId | POST /grades, PATCH /grades/:id, DELETE /grades/:id |
| Sections list / detail | GET /sections(,/:id) | POST /sections, PATCH /sections/:id, DELETE /sections/:id |
| Classes list / detail | GET /classes, GET /classes/by-year/:y, GET /classes/:id | POST /classes, PATCH /classes/:id, DELETE /classes/:id |
| Subjects list / detail | GET /subjects(,/:id) | POST /subjects, PATCH /subjects/:id, DELETE /subjects/:id |
| Assignment matrix / roster | GET /subject-assignments/by-class/:c?academicYearId=, by-teacher/:t?academicYearId= | POST /subject-assignments, DELETE /subject-assignments/:id |
| Explorer | composition above | — |
06 — Screen Specifications (Academics Module)
- 0. Shared chrome & conventions (all screens)
- 1. Academic Year List
/academics/years - 2. Academic Year Detail
/academics/years/:id - 3. Grades List
/academics/grades - 4. Grade Detail
/academics/grades/:id - 5. Classes List
/academics/classes - 6. Class Detail — MASTER
/academics/classes/:id - 7. Section Detail
/academics/sections/:id - 8. Sections List
/academics/sections - 9. Subjects List
/academics/subjects - 10. Subject Detail
/academics/subjects/:id - 11. Subject Assignment Matrix (full screen)
/academics/classes/:id/assign - 12. Hierarchy Explorer
/academics/explore - 13. Teacher Roster
/academics/teachers/:teacherId - Interaction & motion summary (module)
- Keyboard (desktop)
Production-grade specs for each Academics screen: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens:
Sp= 00-shared/02 §3,Ty= typography §2,El= elevation §5,R= radius §4,Mo= motion §6. Components reference 00-shared/03 and module components in 07_Component_Library.md. Wire facts: all reads return raw docs indata; lists paginated withmeta{page,limit,totalItems,totalPages,hasNext,hasPrevious}(pagination-query.dto.ts:32-39).
0. Shared chrome & conventions (all screens)
- Year switcher:
AppDropdownchip in the AppBar (values = cached years, 13_State_Management.md §3); selection is aReferenceScopein the cubit that re-scopes everyacademicYearIdquery; default = current year (isCurrent:true) or first year if none flagged. - Permission gating: all CTAs hidden for read-only roles (P4, 02); route
guard
(planned)foracademics.*perms (OQ-1). Until then, hide by role set. - Status rendering: year status enum → badge (
upcoming=outline,active=tonal/primary,archived=neutral) (academic-year.schema.ts:7-11); grade/section/class status is a free string — treat'active'= on, anything else = archived badge (OQ-2 in 01). - Empty states: one
AppEmptyStateper entity with its primary CTA. - Loading:
AppSkeleton(list)≤ 200 ms to first frame; content ≤ 2 s (00-shared/10 §1). - Errors:
AppErrorStatewith servercode; 409/422 → inline banner; 5xx → generic +requestIdhint; 401 → silent refresh → re-login.
1. Academic Year List /academics/years
Chrome: AppBar "Academic Years" + year switcher (hidden on this screen);
FAB "Add year" (extended, Icons.add); padding Sp.16 (phone) / Sp.24.
Row (AppListTile):
- Leading: year monogram avatar (
Icons.calendar_month) —currentvariantprimaryContainer. - Title:
name(Ty.titleMedium). - Subtitle:
startDate → endDate(localeIntl,dd MMM yyyy),bodySmall onSurfaceVariant. - Trailing: status badge +
AppMenu(Set as current / Edit / Delete).
States:
- idle → list from
GET /academic-years(sort=-createdAtserver default). - paginated:
hasNext→ infinite scroll footer spinner (Mo.m-instant). - pull-to-refresh: re-fetch page 1.
- current-year row pinned first with a
primaryContainerleading pill "Current" (client sort:isCurrentdesc, then server order).
Conflict-free actions: "Set as current" → AppDialog confirm ("This will
un-flag all other years") → PATCH /academic-years/:id/set-current
(academic-year.service.ts:69-83) → row badges re-render; snackbar "Year 2026-2027
is now current". Optimistic: no (server flips multiple docs) — server-first,
success snackbar.
Delete: destructive AppDialog (typed confirm of the year name —
irreversible-ish soft delete, 00-shared/05 §5); on 200 row removed with
AnimatedList exit.
A11y: row Semantics(button) + "current/upcoming/active/archived"; badge
announced; pagination live region "Loaded N of M years".
Tablet/desktop: master-detail at ≥ 840 dp — list left, detail right
(/academics/years/:id); keyboard N = new year; Ctrl+Enter = set current.
2. Academic Year Detail /academics/years/:id
Chrome: AppBar back + title name; header card.
Header card (AppCard tonal when isCurrent):
headlineMediumname; badge status + "Current" pill.- Meta rows (
AppListTile-like): Start date, End date (bodyMedium), Created (bodySmall). - Actions row:
TonalButton"Set as current" (hidden if already current);OutlinedButton"Edit" → form screen; icon menu → Delete.
Classes of this year section (titleMedium):
- Feed =
GET /classes/by-year/:academicYearIdpaginated (class.controller.ts:30-35). - Rows = class name + grade/section chips (client-joined), capacity.
- Tap →
/academics/classes/:id(master screen). - Empty →
AppEmptyState compact"No classes yet for this year" + "Add class".
Edge: year status editable to archived even while isCurrent — client warns
"Archiving the current year?" (server allows it — update-academic-year.dto.ts:20-27,
academic-year.service.ts:59-67).
3. Grades List /academics/grades
Chrome: AppBar "Grades"; FAB "Add grade"; optional grade code chip.
Row:
- Leading: rank badge
displayOrder(mono,Ty.labelMedium, circled). - Title:
name(Ty.titleMedium); trailing:codechip (mono) if set; status badge (non-activeonly);AppMenu(Edit / Delete). - Subtitle: "N sections" (count from client cache of
by-grade) + "N classes".
Ordering: server default displayOrder:1 (grade.service.ts:39) — no client
re-sort; reorder = edit displayOrder (form field, Min(0)).
Create (screen 14.1): full-screen form (5+ fields) per 08 §1.
Conflict: server 409 Grade "Grade 1" already exists. → inline AppBanner (error) at top + autofocus name field (grade.service.ts:23).
Delete: confirm dialog warns "Sections and classes of this grade will remain
but become orphaned" — honest copy; cascade (planned) (OQ-3).
4. Grade Detail /academics/grades/:id
Header: name + code + displayOrder + status; actions Edit/Delete.
Sections section: SectionChips (module component) from
GET /sections/by-grade/:gradeId — tap chip → section detail; "Add section" →
form (section picker pre-filled with this grade).
Classes section: client-joined rows from classes cache where gradeId matches;
shows which year each belongs to (badge) since grades may float across years
(grade.schema.ts:9-10).
5. Classes List /academics/classes
Chrome: AppBar "Classes" + year switcher + filter chips (AppChips): All /
This year / Active only (client filter on status).
Row:
- Leading:
Icons.groupsavatar. - Title:
name(Ty.titleMedium) — e.g. "Grade 10 - A" (create-class.dto.ts:22). - Subtitle: joined chips "Grade 10 · Section A" + year name; capacity right-aligned
mono"40". - Trailing: status badge;
AppMenu(Edit / Delete).
Feed: GET /classes (all) or GET /classes/by-year/:academicYearId when a
year is selected (server-filtered, paginated). Both unsorted → preserve order.
Conflicts: duplicate class names per (year, grade, section) are possible
(class.service.ts:16-18) — client warns with a tertiary "Duplicate" chip on
rows sharing (gradeId, sectionId, name) (OQ-4).
Create (screen 14.3): cascading pickers Year → Grade → Section (13 §2); name auto-suggested "Grade {name} - {section}" (editable).
6. Class Detail — MASTER /academics/classes/:id
Chrome: AppBar back + class name; two tabs: Overview · Subjects &
teachers; FAB: on Overview tab = Edit, on roster tab = Add subject+teacher
(small FAB morph via AnimatedSwitcher, Mo.m-fast).
Overview tab:
- Header card: name
headlineMedium, grade/section/year chips, status badge. - Meta
AppCard: Capacity (mono), Class teacher (name resolved from users cache —classTeacherIdis a user ObjectId percreate-class.dto.ts:32-35), Room (roomId, free string — render as text, OQ-6), Campus (campusId, opaque id — hide unless resolved, OQ-6). - Links section (
AppListTiles): "Timetable" →/timetable?classId=(timetable.controller.ts:22-29); "Attendance" → attendance module; "Students" → students module ((planned)count from class_enrollments).
Subjects & teachers tab (assignment matrix — module core):
- Roster =
GET /subject-assignments/by-class/:classId?academicYearId=(bare array,subject-assignment.controller.ts:24-29). - Rows:
SubjectAssignmentRow— subject name + code chip + shortName, teacher name, trailingAppMenu(Replace / Remove). - Conflict surface: duplicate subject rows →
AppBanner(warning)"Same subject assigned twice" + row highlightedtertiary; empty roster →AppEmptyState compact"No subjects assigned yet" + "Add subject". - Replace = delete + create (no update endpoint,
subject-assignment.controller.ts:19-39); Remove = destructive confirm →DELETE /subject-assignments/:id; stale id → 404Assignment not found.(subject-assignment.service.ts:34) → treat as already-removed, refresh.
Tablet/desktop (≥ 840 dp): detail pane shows tabs side-by-side with the list pane; roster tab renders as a table (columns: Subject | Code | Teacher | Actions) with column headers announced (00-shared/09 §7).
A11y: tab semantics; each roster row = one Semantics(button) group with
action hints; conflict banner in a live region.
7. Section Detail /academics/sections/:id
Header: name headlineMedium, grade chip (joined), status badge.
Meta card: Capacity, Class teacher, Room (same resolution rules as class).
Classes using this section: client-joined rows (from classes cache where
sectionId matches) with year badges.
Actions: Edit / Delete (orphan warning on delete — sections are referenced by
classes with required sectionId, class.schema.ts:18-19).
8. Sections List /academics/sections
Simple list; rows = section name + grade label chip + capacity; filter chips
(All / By grade via GET /sections/by-grade/:gradeId). FAB "Add section"
(grade picker first).
9. Subjects List /academics/subjects
Chrome: AppBar "Subjects"; FAB "Add subject"; search via AppSearchBar
client-side only (server q unused — OQ-7 in 01).
Row:
- Leading:
Icons.menu_book. - Title:
name; subtitle:codemono+shortNameif set. - Trailing: marks summary
bodySmall"100 max · pass 33" (admin/coordinator only — hidden for P4) + status badge +AppMenu.
Ordering: insertion order (subject.service.ts:35-47); no reorder field exists —
client preserves.
10. Subject Detail /academics/subjects/:id
Header: name + code chip + shortName + status.
Marks card (AppCard): grid of four stat tiles — Maximum, Passing, Theory,
Practical (Ty.titleMedium numerals with tabularFigures(), 00-shared/02 §2);
values from subject.schema.ts:21-32. Edit → form without theory/practical
fields (not updatable — update-subject.dto.ts:4-42; flagged, OQ-5).
Usage section: "Assigned to N classes" — client-joined count from cached
rosters; rows link to class detail.
11. Subject Assignment Matrix (full screen) /academics/classes/:id/assign
Full-screen sheet (≥ 3 fields, 00-shared/05 §5):
- Header: class name + year.
- Existing rows (same
SubjectAssignmentRowas §6). - "Add" opens
AppBottomSheet(2/3 height):AppDropdownSubject (searchable) — pre-loaded catalog; entries already assigned to this class are disabled with "Already assigned" note.AppDropdownTeacher (searchable) — user pickers filtered to teacher roles (Users module);(planned)server-side teachers list.- CTA "Assign" →
POST /subject-assignments{teacherId, subjectId, classId, academicYearId}(allIsMongoId,create-subject-assignment.dto.ts:5-19).
- Conflict: if server someday returns 409/422 → banner; today server never rejects duplicates (OQ-4) — client guard only.
12. Hierarchy Explorer /academics/explore
Chrome: AppBar "Structure"; year switcher; search box (client filter on names). Layout:
HierarchyTree(module component, 07 §1): root = Year → children = Grades → Section → Class leaves; each node shows counts (sections/classes/subjects).- Expand/collapse with
AnimatedSize(Mo.m-base); default: years expanded, grades collapsed. - Leaf tap → class master screen; node context menu (Edit / Add child) for
admin/coordinator.
Gap spotting (coordinator): nodes with 0 children get a
tertiarydot + "empty" label; class nodes without subjects get a warning badge. Data: composed client-side from the 4 list endpoints (no tree endpoint exists) — see 15_Flutter_Implementation_Guide.md §4.
13. Teacher Roster /academics/teachers/:teacherId
Chrome: AppBar teacher name; year switcher (query academicYearId).
Rows: class name + subject (+ code), grouped by class (List.groupBy,
00-shared/11 §8). Conflict: same subject in ≥ 2 classes → AppBanner(warning)
- row highlights. Empty: "No teaching load this year". Data:
GET /subject-assignments/by-teacher/:teacherId?academicYearId=(subject-assignment.controller.ts:30-35).
Interaction & motion summary (module)
| Concern | Spec |
|---|---|
| Screen enter | Mo.m-base slide; list→detail uses Hero on row leading avatar → header card (Mo.m-slow) |
| FAB actions | scale-in Mo.m-entrance; hide on scroll-down |
| Chip toggles (filters, year switcher) | Mo.m-fast fill/outline swap |
| Conflict banner | slide-down + fade Mo.m-base; live region |
| Row remove | AnimatedList exit Mo.m-fast; snackbar confirm |
| Reduced motion | all fade ≤ Mo.m-instant (00-shared/08 §4) |
Keyboard (desktop)
N = new on list screens; Ctrl+Enter = submit forms / set-current; Esc =
close sheets/dialogs; Tab logical order Year→Grade→Section→Class→Subject
(00-shared/08 §2).
07 — Component Library (Academics Module)
- 1. HierarchyTree
- 2. SectionChips
- 3. SubjectAssignmentRow
- 4. YearStatusBadge
- 5. EntityStatusBadge (grade/section/class)
- 6. CascadingEntityPicker
- 7. MarksSummaryTiles
- 8. RosterTable (desktop)
- 9. ConflictBanner
- 10. ReferenceCacheProvider (infra widget)
Module-specific widgets. Shared components (00-shared/03) are referenced by name and used as-is (AppCard, AppListTile, AppDropdown, AppChips, AppSearchBar, AppSkeleton, AppEmptyState, AppErrorState, AppBanner, AppSnackbar, AppDialog, AppDatePicker, AppTextField, AppMenu, AppFAB). All names are proposals; the server API facts they bind to are exact.
1. HierarchyTree
- Purpose: renders the Year → Grade → Section → Class structure as an expandable tree (Hierarchy Explorer, 06 §12).
- Props:
nodes(nested model: year → grades → sections → classes),expandedIds: Set<String>,onToggle(id),onNodeTap(node),onNodeMenu(node)(admin),highlightEmpty: bool. - States: loading (
AppSkeletonrows), empty (rootAppEmptyState), error (AppErrorState), expanded/collapsed. - Row anatomy: leading expand icon (rotates
Mo.m-fast), depth indentSp.8× level, entity icon by type, titleTy.titleMedium, subtitle countsbodySmall onSurfaceVariant("4 sections · 8 classes"), trailing status badge / warning dot (empty node,tertiary). - A11y: each node
Semantics(expanded:)+ label "Grade 10, expanded, 4 sections"; keyboard arrows expand/collapse (00-shared/09 §6). - Tokens: node hover
surfaceVariant; expanded child indent guidesoutlineVariant1 px; Flutter: customExpansionTile-freeAnimatedSizelist (keep N expanded ≤ 50 for perf, lazy-load children).
2. SectionChips
- Purpose: dense list of a grade's sections (Grade detail, 06 §4; class rows).
- Props:
sections[{id,name,capacity,status}],selectedId?,onTap,maxChips(default 8 → "+N more"). - States: default, selected (
secondaryContainer), inactive (status≠'active'→ outlined + strike-through name, OQ-2), overflow. - A11y:
Semantics(label: "Section A, capacity 40"); chip group labeled. - Flutter:
WrapofFilterChip/InputChip(00-shared/04 §2).
3. SubjectAssignmentRow
- Purpose: one row in the roster / matrix (06 §6, §11).
- Props:
subject{code,name,shortName},teacherName,conflict: bool,onReplace,onRemove,readOnly. - Anatomy: leading subject avatar (
Icons.menu_book,primaryContainerwhen conflict →tertiaryContainer), titlesubject.name, subtitlecodemono+ shortName, trailing teacher namebodyMedium+AppMenu(Replace/Remove). - Conflict state:
tertiaryleft border 2 px + warning icon + tooltip "Assigned more than once" (client-computed — server allows duplicates, OQ-4). - A11y: whole row one
Semantics(button)with subject + teacher label. - Flutter:
ListTile-custom withAnimatedContainerborder.
4. YearStatusBadge
- Purpose: status chip for
academic_years. - Props:
status: 'upcoming'|'active'|'archived',isCurrent: bool. - Mapping:
upcoming→ outline chip;active→ tonal (primaryContainer);archived→ neutral (surfaceVariant);isCurrentadds a filledprimarypill "Current" (rendered separately, see 06 §1). - Source: enum
AcademicYearStatus(academic-year.schema.ts:7-11).
5. EntityStatusBadge (grade/section/class)
- Purpose: status chip for entities whose schema status is a free string
(default
'active'). - Behaviour:
status === 'active'→ no badge (default, reduces noise) or subtle tonal "Active"; anything else → neutral "Archived"-style badge with the raw value truncated. - Source:
grade.schema.ts:21-22,section.schema.ts:24-25,class.schema.ts:33-34; flag OQ-2 (01).
6. CascadingEntityPicker
- Purpose: Year → Grade → Section cascading dropdown group (Class create form, 06 §5; also Grade → Section standalone).
- Props:
scopedYearId,onYearChanged,grades,sections(already filtered by the parent selection via the ReferenceRepository, 13 §2),onChanged(selection),errorText,readOnly. - Behaviour: parent cleared → children reset + disabled; selections emit a
complete
{academicYearId, gradeId, sectionId}or null. - A11y: group
Semantics; each dropdown role "combobox"; errors announced. - Flutter: three
AppDropdownin a column withAnimatedSizevalidation reveal.
7. MarksSummaryTiles
- Purpose: read-only 4-tile grid for subject marks config (Subject detail, 06 §10).
- Props:
maximumMarks, passingMarks, theoryMarks, practicalMarks. - Anatomy: 2×2 grid of
AppCard-tiles; labellabelMedium, numeralTy.titleMediumwithtabularFigures(). - A11y: one group labeled "Marks configuration", each value announced.
- Source:
subject.schema.ts:21-32defaults (100/33/80/20).
8. RosterTable (desktop)
- Purpose: table variant of the assignment matrix ≥ 840 dp (06 §6).
- Props:
rows,onReplace,onRemove,readOnly. - Anatomy: columns Subject | Code | Teacher | Actions; sticky header
(
surfaceContainerLow), column headers announced; row hoversurfaceVariant. - Flutter:
DataTablewrapped withScrollbar(always visible on desktop, 00-shared/04 §8).
9. ConflictBanner
- Purpose: inline warning for client-computed conflicts (duplicate assignment, empty class, orphan node).
- Props:
kind: duplicate|empty|orphan|teacherOverlap,message,action?. - Tokens:
tertiaryContainerbackground,onTertiaryContainertext,r-md; slide-down + fadeMo.m-base; live region. - Flutter: custom
MaterialBanner-styleAnimatedContainer(notAppBannererror— conflicts are warnings, not errors).
10. ReferenceCacheProvider (infra widget)
- Purpose: top-of-tree widget exposing the 24 h reference cache (13 §3) to all Academics screens.
- Props:
tenantId,yearIdscope; exposesyears, grades, sections, classes, subjects+refreshAll(). - Behaviour: invisible; drives
RefreshIndicatorcascades; emitsReferenceChangedso timetable/attendance screens re-fetch their scoped lists (13 §5). - Flutter:
InheritedNotifierover theReferenceCubit.
Dependency map
| Component | Shared base | Data source |
|---|---|---|
| HierarchyTree | AppCard, AppSkeleton | 4 list endpoints (client join) |
| SectionChips | AppChips | GET /sections/by-grade/:gradeId |
| SubjectAssignmentRow | AppListTile, AppMenu | GET /subject-assignments/by-class/:c?academicYearId= |
| YearStatusBadge | AppChips | GET /academic-years |
| EntityStatusBadge | AppChips | GET /grades / /sections / /classes |
| CascadingEntityPicker | AppDropdown | year/grades/sections lists |
| MarksSummaryTiles | AppCard | GET /subjects/:id |
| RosterTable | DataTable | roster endpoint |
| ConflictBanner | AppBanner pattern | client-computed |
08 — Form Specifications (Academics Module)
- 1. Academic Year — Create (
POST /academic-years) - 2. Academic Year — Update (
PATCH /academic-years/:id) - 3. Grade — Create (
POST /grades) - 4. Grade — Update (
PATCH /grades/:id) - 5. Section — Create (
POST /sections) - 6. Section — Update (
PATCH /sections/:id) - 7. Class — Create (
POST /classes) - 8. Class — Update (
PATCH /classes/:id) - 9. Subject — Create (
POST /subjects) - 10. Subject — Update (
PATCH /subjects/:id) - 11. Subject Assignment — Create (
POST /subject-assignments) - Shared submission behaviour (all forms)
Field-by-field specs for every create/update form. Field metadata (required, type, validation, defaults) is exact from
src/modules/academics/dto/*; server validation via class-validator (ValidationPipe→ 400VALIDATION_ERRORwith per-field details, 00-shared/07 §3). Shared form rules: full-screen page (> 3 fields), submit button loading state (anti-double-submit), errors inline per field + focus first invalid, offline → blocked with banner (00-shared/08 §6, 00-shared/10 §2). Dates:IsDateString= ISO-8601YYYY-MM-DD(swagger examples,create-academic-year.dto.ts:9-15).
1. Academic Year — Create (POST /academic-years)
Fields exact from create-academic-year.dto.ts:4-21:
| Field | Req | Control | Validation (server) | Notes / UI |
|---|---|---|---|---|
name | ✅ | AppTextField | IsString | e.g. "2026-2027"; 409 on duplicate (academic-year.service.ts:25-29) — inline banner, server copy |
startDate | ✅ | AppDatePicker | IsDateString | ISO date; helper "e.g. 2026-04-01" |
endDate | ✅ | AppDatePicker | IsDateString | client rule: must be > startDate (server does not check — OQ-5 01; still block client-side) |
status | ⬜ | AppDropdown | IsString | enum values upcoming/active/archived (academic-year.schema.ts:7-11); omitted → server defaults upcoming (academic-year.service.ts:30-33); keep optional, default "Upcoming" |
- Success: 200 doc → navigate to detail; snackbar "Year created".
2. Academic Year — Update (PATCH /academic-years/:id)
From update-academic-year.dto.ts:4-28: all optional — name, startDate,
endDate, status, isCurrent (bool).
- UI rule:
isCurrentis not shown as a normal checkbox — edits use "Set as current" on the list/detail (PATCH /:id/set-current,academic-year.service.ts:69-83) which also forcesstatus: active. Inline checkbox would fight that; keep the dedicated action. - Server sets only provided fields (
$set,academic-year.service.ts:64).
3. Grade — Create (POST /grades)
From create-grade.dto.ts:4-24:
| Field | Req | Control | Validation | Notes |
|---|---|---|---|---|
name | ✅ | AppTextField | IsString | e.g. "Grade 1"; 409 on duplicate (grade.service.ts:21-24) |
academicYearId | ⬜ | AppDropdown (year) | IsMongoId | schema optional (grade.schema.ts:9-10) — label "Attach to year (optional)"; empty allowed, sent as null/omitted |
code | ⬜ | AppTextField | IsString | e.g. "G1"; auto-uppercase formatter |
displayOrder | ⬜ | AppTextField (number) | IsInt, Min(0), default 0 | ladder position; default = next max+1 (client pre-fill, editable) |
- Success → list refresh (keeps
displayOrdersort,grade.service.ts:39).
4. Grade — Update (PATCH /grades/:id)
From update-grade.dto.ts:4-30: all optional — academicYearId, name, code,
displayOrder, status. status shown only for admin; free-string field with a
suggested picker of active / archived (schema default 'active',
grade.schema.ts:21-22; OQ-2 01).
5. Section — Create (POST /sections)
From create-section.dto.ts:4-28:
| Field | Req | Control | Validation | Notes |
|---|---|---|---|---|
gradeId | ✅ | AppDropdown (grade) | IsMongoId | pre-selected when arriving from grade detail |
name | ✅ | AppTextField | IsString | e.g. "A"; client warns if (gradeId, name) duplicates (server does not check — OQ-4) |
capacity | ⬜ | AppTextField (number) | IsInt, Min(1), default 40 | |
classTeacherId | ⬜ | AppDropdown (users, teacher role) | IsMongoId | label "Class teacher (user)" — create-section.dto.ts:19-21 |
roomId | ⬜ | AppTextField | IsString | free text (OQ-6 01) |
6. Section — Update (PATCH /sections/:id)
From update-section.dto.ts:4-35: all optional — gradeId, name, capacity,
classTeacherId, roomId, status. Reparenting caution: changing gradeId
orphans classes referencing this section (class.schema.ts:18-19 required) —
client shows confirm "Classes of this section keep pointing at it".
7. Class — Create (POST /classes)
From create-class.dto.ts:4-41:
| Field | Req | Control | Validation | Notes |
|---|---|---|---|---|
academicYearId | ✅ | AppDropdown (year) | IsMongoId | cascading picker part 1 (07 §6) |
gradeId | ✅ | AppDropdown (grade) | IsMongoId | part 2; disabled until year picked (UI only — server doesn't cross-check refs) |
sectionId | ✅ | AppDropdown (section, filtered by grade) | IsMongoId | part 3; disabled until grade picked |
name | ✅ | AppTextField | IsString | auto-suggest "Grade {grade.name} - {section.name}" (editable) — example "Grade 10 - A" (create-class.dto.ts:22) |
capacity | ⬜ | AppTextField (number) | IsInt, Min(1), default 40 | |
campusId | ⬜ | AppDropdown (campus) | IsMongoId | hidden (planned) — no campus module/endpoint; schema ref points at Class (OQ-6) |
classTeacherId | ⬜ | AppDropdown (users, teacher role) | IsMongoId | "Class teacher (user)" — create-class.dto.ts:32-35 |
roomId | ⬜ | AppTextField | IsString | free text (OQ-6) |
- No uniqueness on
(academicYearId, gradeId, sectionId, name)(class.service.ts:16-18) — client shows a "Duplicate class?" confirm when the exact combo already exists in cache (OQ-4).
8. Class — Update (PATCH /classes/:id)
From update-class.dto.ts:4-50: all optional — academicYearId, campusId,
gradeId, sectionId, name, capacity, classTeacherId, roomId, status.
Same reparenting caution as section (below, §6).
9. Subject — Create (POST /subjects)
From create-subject.dto.ts:4-47:
| Field | Req | Control | Validation | Notes |
|---|---|---|---|---|
code | ✅ | AppTextField | IsString | e.g. "MATH101"; 409 on duplicate (subject.service.ts:21-25) + DB unique index (subject.schema.ts:38); auto-uppercase |
name | ✅ | AppTextField | IsString | e.g. "Mathematics" |
shortName | ⬜ | AppTextField | IsString | e.g. "Maths" |
credits | ⬜ | AppTextField (number) | IsInt, Min(0), default 0 | |
maximumMarks | ⬜ | AppTextField (number) | IsInt, Min(1), default 100 | |
passingMarks | ⬜ | AppTextField (number) | IsInt, Min(1), default 33 | |
theoryMarks | ⬜ | AppTextField (number) | IsInt, Min(0), default 80 | |
practicalMarks | ⬜ | AppTextField (number) | IsInt, Min(0), default 20 |
- Client cross-validation (server has none — OQ-5 01):
theoryMarks + practicalMarks ≤ maximumMarks;passingMarks ≤ maximumMarks; error under the offending field, block submit.
10. Subject — Update (PATCH /subjects/:id)
From update-subject.dto.ts:4-42: all optional — code, name, shortName,
credits, maximumMarks, passingMarks, status.
theoryMarks / practicalMarks are NOT updatable — do not render those fields in
edit mode; add a helper line "Theory/Practical marks locked (planned)" (OQ-5).
code change → 409 if taken (service checks only on create; a duplicate code
update is not checked and will hit the unique index → 500-mapped 409 by the
exception filter — client surfaces as "Code already in use" on 409/5xx
DUPLICATE per http-exception.filter.ts mapping; flag OQ-2 01).
11. Subject Assignment — Create (POST /subject-assignments)
From create-subject-assignment.dto.ts:4-20:
| Field | Req | Control | Validation | Notes |
|---|---|---|---|---|
classId | ✅ | (context, hidden) | IsMongoId | from the class screen — not user-entered |
academicYearId | ✅ | (context, hidden) | IsMongoId | from year switcher |
subjectId | ✅ | AppDropdown (subjects, searchable) | IsMongoId | already-assigned subjects disabled (client guard, OQ-4) |
teacherId | ✅ | AppDropdown (users, teacher role, searchable) | IsMongoId | (planned) dedicated teachers list |
No update form (no endpoint) — "Replace" flow = confirm dialog → DELETE /subject-assignments/:id → reopen create sheet.
Shared submission behaviour (all forms)
- Submit → button
loading(label replaced, 00-shared/08 §6); fields disabled. - 400 → per-field
errorTextfromerror.details[](field names match DTO keys); focus first invalid. - 401 → silent refresh then retry once; fail → sessionExpired.
- 409 → inline
AppBanner(error)with server message (name/code conflicts). - 429 → countdown banner; no auto-retry (00-shared/07 §4).
- 5xx → generic error +
requestId(never server internals). - Success → snackbar + navigate (list or detail per entity); no optimistic write (server returns canonical doc — reconcile from response).
09 — User Behaviour (Academics Module)
- 1. Expected behaviour (server-contract-driven)
- 2. Common mistakes & countermeasures
- 3. Power & new users
- 4. Mobile & a11y users
- 5. Interrupted sessions & retries
- 6. Abandonment
- 7. Frustration points → UX treatments (summary)
- 8. Cross-device & conflict
- 9. Adoption path (holistic)
Expected behaviour derived from server semantics (
src/modules/academics/services/*)
- shared interaction rules (00-shared/08), the common mistakes users make with structure data, and the UX countermeasures. Purpose: a playbook of what the user does so the flows never surprise.
1. Expected behaviour (server-contract-driven)
- Duplicates are rejected by name/code, never silently merged: creating a year
or grade with an existing name, or a subject with an existing
code, returns 409DUPLICATE_RESOURCEwith exact server copy ("Academic year "X" already exists.",academic-year.service.ts:25-29;grade.service.ts:21-23;subject.service.ts:21-25). The client must never pre-empt with its own copy — render the server message verbatim. set-currentis a global, one-way-ish flip:PATCH /academic-years/:id/set-currentclearsisCurrenton every other year, then sets the targetisCurrent:trueandstatus:"active"(academic-year.service.ts:69-83). The old current year does not go toarchivedautomatically — it staysactiveminus the flag. Users expect "old year becomes archived"; the UI must show the real result.- Delete is soft and silent to children: grade/section/class/assignment delete
=
softDelete(base.repository.ts:68-74); lists simply stop showing the row. Nothing cascades — sections/classes/assignments pointing at a deleted grade remain in the DB and keep appearing in other lists (OQ-3). - Correction of an assignment is delete + recreate: there is no assignment
update endpoint (
subject-assignment.controller.ts:19-39); a wrong teacher is fixed by removing the row and adding a new one. - Ordering is server-given, not user-sortable: grades render in
displayOrderasc (grade.service.ts:37-39); years newest-first (createdAt:-1,academic-year.service.ts:45-47); classes/sections/subjects in insertion order (class.service.ts:41-53,section.service.ts:41-48,subject.service.ts:35-47). Reordering grades = editingdisplayOrder(int ≥ 0), not drag-and-drop (until a reorder endpoint exists —(planned)). q(global search) is accepted but ignored:PaginationQueryDto.q(pagination-query.dto.ts:26-29) is parsed on every list endpoint but no academics service filters on it (services filter{}, e.g.grade.service.ts:41-43). Users will type in a search box and get full lists — client search must be local.- Grade/section/class
statusis an open string (default'active',grade.schema.ts:21-22,section.schema.ts:24-25,class.schema.ts:33-34); only academic-year status is an enum (upcoming|active|archived,academic-year.schema.ts:7-11). Treat any non-activevalue as inactive.
2. Common mistakes & countermeasures
| Mistake | Related | UX countermeasure |
|---|---|---|
| Creating "Grade 1" twice ("1" vs "One" vs "Grade-01") | 409 name check | pre-check against loaded cache with fuzzy hint ("A grade named similar exists"); still render server 409 |
| Year name format drift ("2026-2027" vs "2026-27" vs "2026/27") | 409 | fixed format helper + example in the field; paste-tolerant parser (proposed) |
| Subject code case drift ("math101" vs "MATH101") — server matches exact string | unique code (subject.service.ts:21-25, index subject.schema.ts:38) | auto-uppercase formatter + warning when cache shows a case-variant |
Tapping "Set as current" on an archived/upcoming year | setCurrent forces active (academic-year.service.ts:78-80) | confirm dialog: "This year will become active"; show resulting status |
| Deleting a grade that still has sections/classes | no cascade (OQ-3) | destructive dialog with honest copy "Sections and classes will remain but become orphaned"; suggest archiving instead |
| Double-assigning the same subject to a class | no server duplicate check (OQ-4, subject-assignment.service.ts:13-17) | picker disables already-assigned (subject, teacher) pairs; row-level warning if duplicates exist from another device |
| Fixing an assignment by "editing" the row | no update endpoint | "Replace" flow (delete + recreate) with single confirm, not two steps |
Typing passingMarks > maximumMarks | no cross-field validation server-side (OQ-5) | client-side block + inline error; keep server copy as fallback |
| Expecting search to filter server-side | q unused (OQ-7) | label the search "Filter (on this device)" — or hide until server search lands |
| Reordering classes/sections by drag | insertion order server-side | no drag UI; order = creation order; document in empty-state hint |
3. Power & new users
- Power user (admin/coordinator, desktop): keyboard
N= new entity on list screens,Ctrl+Enter= submit; year switcher keyboard shortcut; bulk-skip pattern in ladder setup (create Grade 1 → "Save & new" stays in form,displayOrderauto-incremented). - New user (fresh tenant): first-run empty states chain — create year →
create grades → create sections → create classes → assign subjects. Each
AppEmptyStateCTA navigates the next step; a mini checklist ("Step 2 of 5") helps structure-first onboarding(proposed).
4. Mobile & a11y users
- One-thumb phone: all admin forms are full-screen with the primary CTA at the bottom (thumb zone); the year switcher and filter chips stay within the top ⅓.
- TalkBack/VoiceOver: tree nodes announce level + counts ("Grade 10, expanded, 4 sections"); chips announce full labels ("Section A, capacity 40"); conflict banners read in a live region.
- Dynamic type 200%: chips and status badges wrap (no fixed widths);
SectionChipsoverflow falls back to "+N more" (07 §2). - Reduced motion: no shake on validation; errors = color + icon + text (00-shared/08 §4).
5. Interrupted sessions & retries
- App killed mid-create form: form state lost (memory only) → return = fresh form; cached lists still available (13 §3) so the user can re-check duplicates.
- Server 5xx on create: generic error +
requestId; form values preserved; suggest retry. Duplicate re-submit risk: a 409 "already exists" then means the first attempt actually succeeded — the client should offer "Open existing". - 401 mid-flow: silent refresh → replay once →
sessionExpired→ re-login, in-progress form preserved in memory. - 429: countdown banner, no auto-retry (00-shared/07 §4).
6. Abandonment
| Where people quit | What to do |
|---|---|
| Year/grade ladder setup mid-way (forms) | no partial writes (single-doc creates); re-entry = empty form; keep "resume" via cached lists |
| Assignment matrix after adding 2 of 5 subjects | no batch API — client memoizes in-progress sheet state; offer "finish later" row on class detail (proposed) |
| Delete confirm dialogs | safe: nothing deleted until confirm; back = cancel |
7. Frustration points → UX treatments (summary)
| Frustration | Treatment |
|---|---|
| 409 on a name that "looks unique" (spacing/case) | show server message verbatim + the conflicting existing row (client lookup by name) |
| Duplicate rows appearing from another device | refresh on screen focus; conflict banner + merge/remove action |
| Long class lists with no ordering | year filter chip defaults "This year"; client group-by-grade view toggle (proposed) |
| Orphaned structure after a delete elsewhere | orphan detection runs client-side (loaded cache); banner + "reparent or archive" action |
| Marks math confusing (theory+practical vs max) | live summary line under the form: "80 + 20 = 100 ≤ max 100" |
8. Cross-device & conflict
- Two admins editing the same structure: last-write-wins — every doc carries a
versioncounter incremented per update (base.repository.ts:57-66) but the client does not send it (PATCH bodies are plain$setDTOs,update-class.dto.ts:4-50); no 409/412 optimistic-concurrency errors surface. UI: refresh lists on focus; treat remote changes as truth. set-currentraced from two devices: both PATCHes run sequentially server-side; the later one wins — the client re-reads the years list after the call to render the true current year.
9. Adoption path (holistic)
P0 (backend already complete): structure browse + create/edit for admin. P1: teacher
roster, student browse, conflict UX. P2 (planned): server-side search (q),
conflict/duplicate validation, cascade jobs (RELATIONSHIPS.md:134-138), coaching
extension (docs/IMPLEMENTATION_PLAN.md §6.2.1).
10 — Interaction Specification (Academics Module)
- 1. Phone / touch
- 2. Keyboard / desktop / mouse
- 3. Haptics (mobile)
- 4. Motion language (module extension of 08 §4)
- 5. Micro-interactions catalogue
- 6. Frustration controls (shared 08 §6 + module)
- 7. Special: deep-link & clipboard interactions
- 8. Validation of interaction to server semantics
Module-specific interactions for hierarchy browsing, master-detail, cascading pickers, and the assignment matrix, layered on shared 00-shared/08. Covers phone/tablet/desktop. Every interaction stays within server semantics (no optimistic writes on deletes/set-current).
1. Phone / touch
| Gesture | Behaviour | Where |
|---|---|---|
| Tap | navigate rows, toggle tree nodes, open chips/sheets, submit | all screens |
| Tap (row trailing) | AppMenu (Edit / Delete / Replace / Remove / Set as current) | lists, roster rows |
| Long-press | same as row menu (tree nodes, roster rows) | explorer, matrix |
| Double-tap | expand/collapse tree node on the label (first tap = select, second = toggle) (proposed) | explorer |
| Swipe left | no destructive swipe — deletes are destructive, keep confirm dialogs | all lists |
| Pull down | RefreshIndicator: re-fetch page 1 (lists) / refetch roster + cache (07 §10) | lists, detail, explorer |
| Pinch | none (no media; tree depth ≤ 5 levels) | — |
| Hardware back | pop to list; sheet → back closes sheet; root → exit shell | all |
| Edge swipe | drawer (phone shell) | shell |
2. Keyboard / desktop / mouse
| Input | Behaviour |
|---|---|
| Tab | logical order Year → Grade → Section → Class → Subject (forms + explorer) |
| Enter | submits the visible form / confirms dialog primary action |
| Esc | closes sheet/dialog, collapses popovers, cancels selection → focus returns to trigger |
| N | new entity on list screens (year/grade/section/class/subject) |
| Ctrl+Enter | submit form; set-current confirm |
| Arrows | navigate tree nodes (↑/↓ move, →/← expand/collapse), dropdown options |
| Space | toggle selected chip / expand node |
| Right-click | context menu = row AppMenu (same actions) |
| Hover | row/tree-node highlight surfaceVariant after 150 ms; tooltip on conflict icon (500 ms) |
| Focus | visible focus ring on every control (00-shared/09 §6) |
3. Haptics (mobile)
| Event | Haptic | Ref |
|---|---|---|
| Create success (year/grade/class/subject/assignment) | lightImpact | — |
| 409 duplicate surfaced | mediumImpact | — |
| Delete confirm (destructive) | heavyImpact | — |
| Tree node expand/collapse | selectionClick | — |
| Chips / year switcher toggle | selectionClick | — |
| Rule | disabled when system haptics off; none on desktop | 08 §3 |
4. Motion language (module extension of 08 §4)
| Transition | Duration/curve | Used by |
|---|---|---|
| List → detail push | m-base slide; Hero on row leading avatar → header card (m-slow) | year/grade/class/subject detail |
| Sheet (add assignment) | m-base bottom slide, 2/3 height | matrix add sheet |
| Tree expand/collapse | AnimatedSize m-base; children stagger m-fast | explorer |
| Conflict banner in/out | slide-down + fade m-base | class detail, roster, explorer |
| Row removal | AnimatedList exit m-fast + snackbar | lists, roster |
| Status badge change (set-current) | scale pop m-fast on new "Current" pill | year list/detail |
| Chips filter toggle | fill/outline swap m-fast | lists, explorer |
| FAB morph (Overview → Subjects tab) | AnimatedSwitcher m-fast | class detail |
Reduced-motion: all of the above degrade to fades ≤ m-instant; no slide or scale.
5. Micro-interactions catalogue
| Interaction | Detail |
|---|---|
| Cascading picker parent change | children reset + disabled with AnimatedSize shrink; new options fade in; selection emits {academicYearId, gradeId, sectionId} or null (07 §6) |
| "Add subject" in matrix | sheet opens; subject dropdown searchable; already-assigned entries disabled with "Already assigned" caption |
| Duplicate warn on class name | as the user types the name, cache lookup on (year, grade, section, name) shows inline "Duplicate class?" confirm on submit, not on keystroke |
| Subject form marks | live summary line updates per keystroke: "theory 80 + practical 20 = 100 ≤ max 100"; invalid combo disables submit |
| Set as current | server-first: button loading → 200 → badges re-render (old current loses pill with m-fast fade) → snackbar |
| Remove assignment | confirm dialog → server-first → row exit AnimatedList; 404 stale id → treat as removed + refresh |
| Year switcher change | all scoped lists reload (year param), skeleton only in list body, AppBar chip swaps m-fast |
| Pull-to-refresh cascade | list refreshes first, then cache (refreshAll), then downstream consumers re-fetch scoped data (13 §5) |
6. Frustration controls (shared 08 §6 + module)
- All presses respond < 100 ms with
m-fastripple. - Every network action shows pending state (button loading / skeleton / spinner).
- Submit disabled while pending — no double-submit (anti-double-create: the worst failure here is a duplicate 409 that actually succeeded on the server).
- 429 → countdown, never auto-retry.
- Errors persist until corrected or dismissed; conflict banners stay until resolved (not auto-timeout) since they change the meaning of the roster.
7. Special: deep-link & clipboard interactions
studylyon://academics/classes/:id→ class master screen directly.studylyon://academics/explore?yearId=→ explorer scoped to a year.- ObjectId paste: admin/dev build accepts pasted MongoId in search fields to jump
to an entity (hidden behind
kDebugModeflag). - Roster table (desktop):
SelectableTexton code/IDs, Ctrl+C works.
8. Validation of interaction to server semantics
| Interaction claim | Server check |
|---|---|
| "Set as current" confirms global un-flagging | setCurrent loops all isCurrent:true docs (academic-year.service.ts:72-77) — confirmed |
| Delete of grade shows orphan warning, no cascade | remove = bare softDelete (grade.service.ts:58-61), no children checks — confirmed |
| Replace assignment = delete + create | no update endpoint (subject-assignment.controller.ts:19-39) — confirmed |
| Client duplicate guard only, never claims server rejection | no unique index/check on assignments/classes (subject-assignment.schema.ts:22-25, class.service.ts:16-18) — confirmed |
Year switcher re-scopes via academicYearId query param | by-year / by-class / by-teacher take academicYearId (class.controller.ts:30-35, subject-assignment.controller.ts:24-35) — confirmed |
| Status free-string treated conservatively | schema defaults only (grade.schema.ts:21-22) — confirmed |
11 — Design System Mapping (Academics Module)
- 1. Theme recipes (module)
- 2. Widget → token
- 3. Typography mapping
- 4. Iconography
- 5. Motion tokens (values 00-shared/02 §6)
- 6. Contrast & dark parity
- 7. Breakpoint behaviour
Academics module → Material 3 / Flutter / token mapping, extending 00-shared/04. Every color, type, spacing, elevation, radius, icon, motion reference traces to 00-shared/02_Design_Tokens.md. Module components defined in 07_Component_Library.md.
1. Theme recipes (module)
| Screens | Theme | Exception |
|---|---|---|
| Structure lists (years/grades/classes/sections/subjects) | standard shell surface | current-year row = primaryContainer |
| Class detail (master) | standard shell | roster conflict rows = tertiaryContainer |
| Assignment matrix sheet | surfaceContainerLow sheet | disabled already-assigned options = surfaceVariant text |
| Hierarchy explorer | standard shell | empty nodes / warning dots = tertiary |
| Read-only browse (student/parent) | standard shell, all CTAs hidden | no destructive accents visible |
| Destructive actions (delete/remove/set-current) | error + errorContainer dialogs | — |
2. Widget → token
| Component | M3 / Flutter | Tokens | Elev | Radius |
|---|---|---|---|---|
| Year row (current) | AppListTile in AppCard tonal | primaryContainer, border outlineVariant | e-1 | r-lg |
| Grade ladder row | AppListTile | leading displayOrder circle surfaceVariant, mono labelMedium | — | r-full |
| Class row chips | AppChips | grade chip secondaryContainer, section chip surfaceVariant, year chip outline | — | r-sm |
HierarchyTree node | custom list (07 §1) | hover surfaceVariant; indent guide outlineVariant 1 px | — | r-md |
| Node empty/warning dot | AppBadge | tertiary (empty), tertiaryContainer (warning) | — | r-full |
SectionChips | Wrap of InputChip | selected secondaryContainer; inactive status≠active outlined | — | r-sm |
SubjectAssignmentRow | custom ListTile | conflict: tertiaryContainer bg + 2 px tertiary left border | — | r-md |
YearStatusBadge | AppChips | upcoming outline; active primaryContainer; archived surfaceVariant | — | r-sm |
EntityStatusBadge | AppChips | non-active → neutral surfaceVariant, raw value truncated | — | r-sm |
CascadingEntityPicker | column of AppDropdown | fill surfaceVariant, focus primary; disabled child surfaceContainerLow | — | r-md |
MarksSummaryTiles | 2×2 AppCard tiles | numerals titleMedium mono + tabularFigures; label labelMedium | e-1 | r-lg |
RosterTable (≥840) | DataTable | header surfaceContainerLow sticky; hover surfaceVariant | — | — |
ConflictBanner | custom MaterialBanner-style | tertiaryContainer bg / onTertiaryContainer text | e-1 | r-md |
| Matrix add sheet | AppBottomSheet | surfaceContainerHigh | e-4 | r-xl (top) |
| Destructive dialog | AppDialog | errorContainer icon + error confirm | e-5 | r-lg |
| FAB (add entity) | FloatingActionButton.extended | primaryContainer | e-3 | r-full |
| Empty state | AppEmptyState | icon onSurfaceVariant 40 % | — | — |
3. Typography mapping
| Token | Screen element |
|---|---|
headlineMedium | detail header names (year/grade/class/subject) |
titleLarge | form titles, dialog titles, explorer root nodes |
titleMedium | list row titles, tab labels, section headers |
bodyMedium | meta rows (capacity, teacher, room), roster teacher names |
bodySmall | dates, hints, "N sections · N classes" counts, marks summary |
labelLarge | buttons, FAB label, tabs |
labelMedium | chips, badges, displayOrder circles |
mono | subject code, capacity numerals, displayOrder, marks numerals |
Numerals (capacity, marks, orders) use FontFeature.tabularFigures() so ladder
columns don't jitter (00-shared/02 §2).
4. Iconography
Material Symbols outlined; module map: calendar_month, school, groups,
menu_book, book_2 (subjects), account_tree (explorer), expand_more/less
(tree), warning_amber (conflicts), check_circle (current), add,
swap_horiz (replace), delete_outline, search, filter_list, arrow_back.
5. Motion tokens (values 00-shared/02 §6)
| Purpose | Token | Applied to |
|---|---|---|
| List → detail push | m-base slide + Hero m-slow | all detail pushes |
| Tree expand/collapse | m-base (AnimatedSize) | explorer |
| Sheet (add assignment) | m-base bottom slide | matrix |
| Row exit on delete/remove | m-fast (AnimatedList) | lists, roster |
| Badge change (set-current) | m-fast scale pop | year screens |
| Conflict banner | m-base slide-down + fade | detail/explorer |
| Filter chips swap | m-fast fill/outline | lists |
Rules: reduced-motion substitutes fades/instants only (08 §4).
6. Contrast & dark parity
- All text ≥ 4.5:1; interactive chrome (dots, borders) ≥ 3:1 (00-shared/09 §2).
- Dark: conflict surface =
tertiaryContainer(kept distinct from error banners); current-year pillprimaryContainerwithonPrimaryContainertext. - No sole-color status: status always badge + icon/text label; conflicts always banner + row highlight + icon (color-blind safe, 00-shared/09 §9).
- Tree indent guides:
outlineVarianton light,outlineat 40 % on dark.
7. Breakpoint behaviour
| Class | Lists | Detail | Explorer / Matrix |
|---|---|---|---|
| phone <600 | single column, FAB bottom | pushed (full screen) | tree full width; matrix sheet full-height |
| 600–839 (tablet) | two-column grid (classes by grade groups) | master-detail split | tree with search box pinned |
| ≥840 | list + detail side-by-side (StatefulShellBranch, 00-shared/05 §3) | right pane tabs | roster as RosterTable; explorer 2-pane (tree |
| ≥1200 | max content width 1280, centered | same | same |
Desktop extras: hover rings on rows/nodes, right-click menus, keyboard shortcuts
(10 §2), always-visible Scrollbar (00-shared/04 §8).
12 — API Mapping (Academics Module)
- 0. Module-wide request envelope & client policy
- 1. Academic Years —
academic-year.controller.ts - 2. Grades —
grade.controller.ts - 3. Sections —
section.controller.ts - 4. Classes —
class.controller.ts - 5. Subjects —
subject.controller.ts - 6. Subject Assignments —
subject-assignment.controller.ts - 7. Loading / streaming / realtime
- 8. Client-side error mapping table (module)
- 9. Pagination & sort (per list)
- 10. Optimistic / undo
Exact wire contract for every screen → endpoint. Base
/api/v1; envelope per 00-shared/07 §2-§3. All endpoints fromsrc/modules/academics/controllers/*; business rules fromservices/*; field contracts fromdto/*; defaults/enums fromschemas/*. Guard today:JwtAuthGuardonly (academics.module.ts:46, every controller@UseGuards(JwtAuthGuard)); RBAC(planned)— OQ-1.
0. Module-wide request envelope & client policy
| Aspect | Contract |
|---|---|
| Base | https://api.<domain>/api/v1 |
| Headers | Authorization: Bearer <accessToken>; x-request-id client-generated; Content-Type: application/json |
| Success | {success:true, message:"OK", data, meta?, timestamp, requestId} |
| Error | {success:false, message, error:{code, details?}, timestamp, requestId} |
| Tenancy | tenantId from JWT claim only — never in body/query (base.repository.ts:20-30) |
| IDs | _id, createdAt, updatedAt, __v returned raw (Mongoose docs, no whitelisting) — client maps explicitly (base.schema.ts:7-35) |
| Pagination | page (≥1, default 1), limit (1–100, default 20), sort (-field = desc), q (parsed, unused by services — OQ-7); meta {page,limit,totalItems,totalPages,hasNext,hasPrevious} (pagination-query.dto.ts:5-30,32-55) |
| Retry | backoff on 5xx/network; no auto-retry on 429 (00-shared/07 §4) |
| Offline | writes blocked + banner; reads from 24 h cache (13 §3) |
1. Academic Years — academic-year.controller.ts
| Method & Path | Body / Params | Success data | Rules & Errors |
|---|---|---|---|
POST /academic-years (:24) | {name*, startDate*, endDate*, status?} | year doc | 409 DUPLICATE_RESOURCE "Academic year "X" already exists." (academic-year.service.ts:25-29); status omitted → upcoming (:30-33) |
GET /academic-years (:27) | page,limit,sort,q | array + meta | default sort=createdAt:-1 (:45-47); sort maps - prefix → desc (:45-47) |
GET /academic-years/:id (:30) | — | year doc | 404 "Academic year not found." (:36-40) |
PATCH /academic-years/:id (:33) | any of {name?, startDate?, endDate?, status?, isCurrent?} | year doc | partial $set (:59-67); 404 if missing; no duplicate-name check on update (OQ-2) |
PATCH /academic-years/:id/set-current (:39) | — | year doc (isCurrent:true, status:"active") | un-sets isCurrent on all others (:69-83); 404 if missing |
DELETE /academic-years/:id (:42) | — | {message:"OK"} | soft delete (:85-88); 404 if missing; no cascade (OQ-3) |
DTO sources: create-academic-year.dto.ts:4-21 (name IsString, startDate/endDate
IsDateString ISO YYYY-MM-DD, status IsString optional); update-academic-year.dto.ts:4-28
(all optional incl. isCurrent bool).
Client notes: isCurrent editable via PATCH body but the UI uses set-current
(which also forces status:"active", academic-year.service.ts:78-80); treat
isCurrent:true as a tenant-wide singleton (blueprint rule, COLLECTIONS.md:1023).
2. Grades — grade.controller.ts
| Method & Path | Body / Params | Success data | Rules & Errors |
|---|---|---|---|
POST /grades (:24) | {name*, academicYearId?, code?, displayOrder?} | grade doc | 409 DUPLICATE_RESOURCE "Grade "X" already exists." (grade.service.ts:21-23); displayOrder default 0 |
GET /grades (:27) | page,limit,sort,q | array + meta | default sort=displayOrder:1 (:37-39) |
GET /grades/:id (:30) | — | grade doc | 404 "Grade not found." (:28-32) |
PATCH /grades/:id (:33) | any of {academicYearId?, name?, code?, displayOrder?, status?} | grade doc | partial $set (:51-56); no rename duplicate check (OQ-2) |
DELETE /grades/:id (:36) | — | {message:"OK"} | soft delete (:58-61); no cascade — sections/classes orphaned (OQ-3) |
DTO sources: create-grade.dto.ts:4-24 (name IsString; academicYearId IsMongoId
optional — schema optional grade.schema.ts:9-10; code IsString; displayOrder
IsInt Min(0) default 0); update-grade.dto.ts:4-30 (adds status IsString).
3. Sections — section.controller.ts
| Method & Path | Body / Params | Success data | Rules & Errors |
|---|---|---|---|
POST /sections (:24) | {gradeId*, name*, capacity?, classTeacherId?, roomId?} | section doc | no duplicate guard (OQ-4) (section.service.ts:16-18); capacity default 40 |
GET /sections (:27) | page,limit,sort,q | array + meta | insertion order (:41-48) |
GET /sections/by-grade/:gradeId (:30) | page,limit | array + meta | scoped {gradeId} (:26-39) |
GET /sections/:id (:36) | — | section doc | 404 "Section not found." (:20-24) |
PATCH /sections/:id (:39) | any of {gradeId?, name?, capacity?, classTeacherId?, roomId?, status?} | section doc | partial $set; reparenting orphans classes (class.schema.ts:18-19 required) — client confirm (OQ-3) |
DELETE /sections/:id (:42) | — | {message:"OK"} | soft delete (:62-65); no cascade |
DTO sources: create-section.dto.ts:4-28 (gradeId IsMongoId; name IsString e.g. "A";
capacity IsInt Min(1) default 40; classTeacherId IsMongoId optional — user ID;
roomId IsString optional); update-section.dto.ts:4-35 (adds status).
4. Classes — class.controller.ts
| Method & Path | Body / Params | Success data | Rules & Errors |
|---|---|---|---|
POST /classes (:24) | {academicYearId*, gradeId*, sectionId*, name*, capacity?, campusId?, classTeacherId?, roomId?} | class doc | no duplicate guard (OQ-4) (class.service.ts:16-18); capacity default 40 |
GET /classes (:27) | page,limit,sort,q | array + meta | insertion order (:41-53) |
GET /classes/by-year/:academicYearId (:30) | page,limit | array + meta | scoped {academicYearId} (:26-39) |
GET /classes/:id (:36) | — | class doc | 404 "Class not found." (:20-24) |
PATCH /classes/:id (:39) | any of {academicYearId?, campusId?, gradeId?, sectionId?, name?, capacity?, classTeacherId?, roomId?, status?} | class doc | partial $set (:55-60) |
DELETE /classes/:id (:42) | — | {message:"OK"} | soft delete (:62-65); downstream refs (timetable/attendance/assignments) remain (OQ-3) |
DTO sources: create-class.dto.ts:4-41 (academicYearId,gradeId,sectionId IsMongoId
required; name IsString e.g. "Grade 10 - A"; campusId IsMongoId optional — ref
points at Class schema, likely Campus typo, class.schema.ts:12-13, OQ-6;
classTeacherId IsMongoId optional user ID; roomId IsString optional free text);
update-class.dto.ts:4-50 (adds status).
5. Subjects — subject.controller.ts
| Method & Path | Body / Params | Success data | Rules & Errors |
|---|---|---|---|
POST /subjects (:24) | {code*, name*, shortName?, credits?, maximumMarks?, passingMarks?, theoryMarks?, practicalMarks?} | subject doc | 409 "Subject with code "X" already exists." (subject.service.ts:21-25) + DB unique index fallback (subject.schema.ts:38); defaults 0/100/33/80/20 |
GET /subjects (:27) | page,limit,sort,q | array + meta | insertion order (:35-47) |
GET /subjects/:id (:30) | — | subject doc | 404 "Subject not found." (:29-33) |
PATCH /subjects/:id (:33) | any of {code?, name?, shortName?, credits?, maximumMarks?, passingMarks?, status?} | subject doc | partial $set (:49-54); theoryMarks/practicalMarks NOT updatable (update-subject.dto.ts:4-42, OQ-5); duplicate code on update → unique index error → 409-mapped by filter (http-exception.filter.ts:32, OQ-2) |
DELETE /subjects/:id (:36) | — | {message:"OK"} | soft delete (:56-59); assignments referencing it stay (OQ-3) |
DTO sources: create-subject.dto.ts:4-47 (code,name IsString; shortName IsString;
credits IsInt Min(0) default 0; maximumMarks IsInt Min(1) default 100;
passingMarks IsInt Min(1) default 33; theoryMarks IsInt Min(0) default 80;
practicalMarks IsInt Min(0) default 20); update-subject.dto.ts:4-42 as above.
No cross-field validation (theory+practical ≤ max, passing ≤ max) server-side (OQ-5).
6. Subject Assignments — subject-assignment.controller.ts
| Method & Path | Body / Params | Success data | Rules & Errors |
|---|---|---|---|
POST /subject-assignments (:21) | {teacherId*, subjectId*, classId*, academicYearId*} (all IsMongoId) | assignment doc | no duplicate/conflict check (OQ-4) (subject-assignment.service.ts:13-17) |
GET /subject-assignments/by-class/:classId (:24) | ?academicYearId= | bare array (no meta) | {classId, academicYearId} (:19-24) |
GET /subject-assignments/by-teacher/:teacherId (:30) | ?academicYearId= | bare array (no meta) | {teacherId, academicYearId} (:26-31) |
DELETE /subject-assignments/:id (:36) | — | {message:"OK"} | soft delete; 404 "Assignment not found." (:33-36) |
No GET /:id and no update endpoint — remove + recreate is the only correction
path (see 08 §11 "Replace" flow). Assignment tuples are not unique per
(classId, subjectId) (subject-assignment.schema.ts:22-25 indexes are non-unique).
7. Loading / streaming / realtime
| Screen | Loading | Streaming | Realtime |
|---|---|---|---|
| All lists | AppSkeleton rows ≤ 200 ms, infinite-scroll footer | — | — |
| Detail | header skeleton + section skeletons | — | — |
| Explorer | per-level skeletons as children load | — | — |
| Any | — | — | (planned) WS push on set-current/structure change (00-shared/07 §8) — until then refresh-on-focus |
8. Client-side error mapping table (module)
| Screen | code | UI |
|---|---|---|
| Any create/update | 400 VALIDATION_ERROR | per-field errorText from error.details[] (keys = DTO field names); focus first invalid |
| Any | 401 UNAUTHENTICATED | silent refresh → replay once → sessionExpired |
| Any write | 403 PERMISSION_DENIED | hidden CTAs + guarded routes (planned) — OQ-1 |
| Year/Grade create | 409 DUPLICATE_RESOURCE | inline AppBanner(error) with server message verbatim |
| Subject create/update code | 409 | "Code already in use" + open existing row |
| Class/Section/Assignment | (server never 409s) | client duplicate guard only (OQ-4) |
| Delete / set-current | 404 RESOURCE_NOT_FOUND | treat as already-removed → refresh; snackbar |
| Any | 429 RATE_LIMITED | countdown banner; no auto-retry |
| Any | 422 BUSINESS_RULE_VIOLATION | (planned) — no service throws it today; map as generic business error |
| Any | 5xx INTERNAL_SERVER_ERROR | generic + requestId; never server internals |
9. Pagination & sort (per list)
| List | Default sort | Server filter support | q |
|---|---|---|---|
| Years | createdAt:-1 (academic-year.service.ts:45-47) | — | unused (OQ-7) |
| Grades | displayOrder:1 (grade.service.ts:37-39) | — | unused |
| Sections | insertion (section.service.ts:41-48) | by-grade/:gradeId | unused |
| Classes | insertion (class.service.ts:41-53) | by-year/:academicYearId | unused |
| Subjects | insertion (subject.service.ts:35-47) | — | unused |
| Assignments | insertion (bare array, subject-assignment.service.ts:19-31) | by-class, by-teacher (+ academicYearId) | n/a |
Client policy: preserve server order always; pagination via meta{hasNext} infinite
scroll; by-class/by-teacher render all rows (no meta).
10. Optimistic / undo
- No optimistic writes anywhere: creates/updates/deletes/set-current are server-first; response doc is the canonical state (00-shared/07 §9).
- Exceptions: filter chips + year switcher are local UI state (optimistic by nature, re-scope queries); client duplicate pre-checks are advisory only.
- Delete/remove confirmations are destructive and never undoable (soft delete —
restoration
(planned)).
13 — State Management (Academics Module)
- 1. Module state model
- 2. Cascading pickers (Cubit)
- 3. Reference-data caching (24 h)
- 4. ReferenceScope (year switcher)
- 5. Refresh propagation to timetable / attendance
- 6. Per-screen Cubits (events → state)
- 7. Error states per action
- 8. Realtime
- 9. Testing hooks (
00-shared/06 §6) - 10. Cross-cutting interplay
Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) + the module-wide reference cache and year scope that every Academics screen and downstream consumer (timetable/attendance) depends on. Backed by repositories calling the endpoints in 12_API_Mapping.md.
1. Module state model
ReferenceCubit (single instance per tenant scope)
├─ years: [AcademicYear] (list cache)
├─ grades: [Grade] (list cache)
├─ sections: [Section] (list cache)
├─ classes: [Class] (list cache)
├─ subjects: [Subject] (list cache)
├─ rosters: {classId: [SubjectAssignment]} (per-class cache, LRU 20)
├─ selectedYearId: String? (ReferenceScope)
└─ ttl: 24 h per collection, refreshed by pull-to-refresh cascade
Screen cubits (scoped, ephemeral)
├─ YearListCubit / GradeListCubit / ClassListCubit / SectionListCubit / SubjectListCubit
├─ YearFormCubit / GradeFormCubit / ClassFormCubit / SectionFormCubit / SubjectFormCubit
├─ AssignmentMatrixCubit
├─ ExplorerCubit
└─ RosterCubit (by-teacher)
2. Cascading pickers (Cubit)
Class create / section create drive the cascades (07 §6). Backed by
CascadingPickerCubit:
state: { yearId?, gradeId?, sectionId?, grades[], sections[], loadingLevel, error }
| Event | Transition |
|---|---|
YearSelected(id) | set yearId; load grades (GET /grades, cached); clear gradeId,sectionId; enable grade dropdown |
GradeSelected(id) | set gradeId; load sections — GET /sections/by-grade/:id if not in cache, else filter cache by gradeId; clear sectionId; enable section dropdown |
SectionSelected(id) | set sectionId; emit complete {yearId, gradeId, sectionId} |
Clear (parent reset) | cascade children disabled + reset |
- Sources per
12 §2-§4: year list from cache; grade list from cache (filtered byacademicYearIdif the grade carries one — grades may float,grade.schema.ts:9-10— otherwise shown in all years); sections from cache/by-grade. - Auto-suggested class name:
"Grade {grade.name} - {section.name}"(example "Grade 10 - A",create-class.dto.ts:22) — editable, not forced. - Errors: fetch failure at any level → retry affordance on that level only; the rest of the form stays intact.
3. Reference-data caching (24 h)
ReferenceCubitfetches each collection on first touch withttl 24 h(00-shared/06 §3.3):GET /academic-years,/grades,/sections,/classes,/subjects(page 1, limit 100 — fetch all pages whenhasNext, since the cache serves joins everywhere).- Invalidation:
refreshAll()(pull-to-refresh cascade) re-fetches all five; writes (create/update/delete/set-current) invalidate the single affected collection (not all) and merge the server response doc. - Persistence: collections cached in
SharedPreferences/Hive(non-sensitive reference data;00-shared/11 §11); loaded synchronously on boot → screens render instantly, revalidate in background. Seed on first boot. - Budget: 12 grades × 4 sections × 40 classes × 30 subjects ≈ < 4 MB raw JSON;
acceptable for
local_cachetier (00-shared/06 §3.3). - Offline: cache serves reads; writes blocked with
AppOfflineBanner(00-shared/07 §10).
4. ReferenceScope (year switcher)
selectedYearIdlives inReferenceCubit; default =isCurrent:trueyear from cache, else first year, else null (academic-year.schema.ts:31-32).- Every list screen that supports a year param subscribes:
ClassListCubitswitchesGET /classes/by-year/:idvsGET /classeswhen "All years" selected; assignment rosters passacademicYearId(subject-assignment.controller.ts:24-35). - Switching year re-scopes in-flight queries, cancels stale ones (bloc
EmitafterisClosedguard), keeps pagination state per year in aMap<yearId, PageState>.
5. Refresh propagation to timetable / attendance
Server has no publish/notify on structure change; the client propagates via the
ReferenceChanged domain event (00-shared/06 §3.4 — local event bus):
ReferenceCubit ── ReferenceChanged (after any write / refreshAll) ──▶
├─ TimetableCubit → re-fetch scoped entries (classId/teacherId filter,
│ timetable.controller.ts:20-29) — drop cached entries whose
│ classId/subjectId/teacherId no longer resolve
├─ AttendanceCubit → re-fetch day lists (classId filter, attendance.schema.ts:28-29)
└─ RosterCubit → re-fetch by-teacher rows
- Consumers keep their own page state; they only reload when the event fires while their screen is mounted, or on focus (app-resume/focus handler).
- Class deletion emits
ReferenceChanged→ timetable/attendance screens show a "class no longer exists" notice instead of a broken roster (they keep stale rows server-side — OQ-3; client hides by re-resolving).
6. Per-screen Cubits (events → state)
| Screen | Cubit | Events → State |
|---|---|---|
| Year list | YearListCubit | Load, LoadMore, Refresh, SetCurrent(id), Delete(id) → {initial, loading, loaded(paged, isCurrentId), empty, error, busyId} |
| Grade list | GradeListCubit | Load, LoadMore, Refresh, Delete(id) → same shape |
| Class list | ClassListCubit | Load(scope: all|yearId), LoadMore, Refresh, ScopeChanged, Delete(id) → same + per-year page map |
| Section list | SectionListCubit | Load(scope: all|gradeId), LoadMore, Refresh |
| Subject list | SubjectListCubit | Load, LoadMore, Refresh, Delete(id) + local filter(text) (server q unused — OQ-7) |
| Class detail | ClassDetailCubit | Load(id) → {loading, loaded(class, roster, joined), error} |
| Assignment matrix | AssignmentMatrixCubit | Load, Add(subjectId,teacherId), Replace(oldId, subjectId,teacherId), Remove(id) |
| Roster (teacher) | RosterCubit | Load(teacherId, yearId), Refresh |
| Explorer | ExplorerCubit | Load, Expand(nodeId), Collapse(nodeId), ScopeChanged — tree built from cache (15 §4) |
| All forms | *FormCubit | Submit(dto) → {idle, submitting, success(doc), duplicate(serverMsg), error(code)} |
All list cubits mix in the shared PaginationCubit (00-shared/06 §3.2).
7. Error states per action
| Action | Error | State → |
|---|---|---|
| create (any) | 409 | duplicate(serverMsg) → inline banner + autofocus first field |
| create | 400 | error(details[]) → per-field |
| set-current | 404 | refresh list; snackbar "Already current / removed" |
| assignment add | 400 | sheet stays, field error |
| assignment remove | 404 | treat-as-removed → refresh roster |
| delete | 404 | treat-as-deleted → refresh |
| any | 5xx | generic + requestId |
| any (offline) | connectivity | blocked + AppOfflineBanner |
8. Realtime
- No WS endpoint for academics today;
(planned)pushes:AcademicYearChanged,StructureChangedevents to tenant room (00-shared/07 §8). Until then: refresh-on-focus +ReferenceChangedlocal bus only. set-currentperformed elsewhere (another device) is caught on next focus: years list re-read → current badge corrects itself.
9. Testing hooks (00-shared/06 §6)
- Pure-Dart cubits; unit-test: cascade transitions (parent clear → children reset), per-year pagination maps, TTL expiry + revalidate, duplicate-merge on 409.
- Widget tests: class form cascade (year → grade → section disable logic), roster conflict rendering, explorer expand/collapse with ≤ 50 open nodes.
- Golden tests:
HierarchyTree,SectionChips,SubjectAssignmentRow(00-shared/10 §9).
10. Cross-cutting interplay
AuthCubit: academics screens requireauthenticated; tenant change (multi-tenant user) wipesReferenceCubitand reseeds for the new tenantId.ConnectivityCubit: gates writes; cache stays readable.FeatureFlagsCubit:(planned)gates — coaching extension (docs/IMPLEMENTATION_PLAN.md:314-317) flips section→batch semantics when enabled; no code today.AnalyticsService(proposed): eventsacademics.*— year_created, set_current, structure_created(type), assignment_added/removed, conflict_seen (00-shared/10 §8).
14 — QA Checklist (Academics Module)
- 1. Functional — CRUD & uniqueness
- 2. Functional — set-current & status
- 3. Functional — hierarchy & ordering
- 4. Functional — delete & cascade behaviour
- 5. Functional — referential & tenant isolation
- 6. Visual / layout
- 7. Accessibility (00-shared/09)
- 8. Performance
- 9. Offline & network (00-shared/10 §2)
- 10. Tablet / desktop
- 11. Localization
- 12. Dark mode / animations / security
- 13. Server-backed edge mirrors
- QA scripts
Module QA specific to framework + shared 00-shared/10. Every check derives from a specific backend behavior so failure analysis is direct.
1. Functional — CRUD & uniqueness
-
Create academic year returns 200 with
status:"upcoming"default (academic-year.service.ts:30-33). -
Duplicate year name → 409
DUPLICATE_RESOURCEmessage verbatim ("Academic year "X" already exists.",academic-year.service.ts:27-28). -
Duplicate grade name → 409 (
grade.service.ts:23). -
Duplicate subject
code→ 409 (subject.service.ts:23-24); race path: two parallel POSTs → one succeeds, second 409 via unique index (subject.schema.ts:38) — client must surface as duplicate, not 5xx. -
Duplicate class
(academicYearId, gradeId, sectionId, name)→ server accepts (OQ-4) — client shows "Duplicate class?" warn before submit; verify no false block when the exact combo differs by one field. -
Duplicate section
(gradeId, name)→ server accepts (OQ-4) — client warning only. -
Duplicate assignment
(classId, subjectId)→ server accepts (OQ-4) — client picker disables already-assigned pairs. -
Update endpoints accept partial bodies only (all-optional DTOs) — empty
PATCH {}returns 200 unchanged doc, no error. -
PATCH /academic-years/:idwith duplicatename→ no 409 (update path has no name check, OQ-2) — client pre-checks from cache. -
PATCH /subjects/:idcannot changetheoryMarks/practicalMarks— fields not in DTO (update-subject.dto.ts:4-42); unknown fields → 400VALIDATION_ERROR(whitelist validation).
2. Functional — set-current & status
-
set-currentun-flags ALL other years, flags target, setsstatus:"active"(academic-year.service.ts:69-83) — verify with 3 years in DB. -
set-currenton anarchivedyear → becomesactive(server allows; confirm copy reflects). -
Exactly one
isCurrent:trueafter any sequence of set-current calls (singleton rule,COLLECTIONS.md:1023). -
Year
statusaccepts onlyupcoming|active|archivedat the client (enumacademic-year.schema.ts:7-11); server accepts any string (IsString) — client must never send unknown values. -
Grade/section/class
statusfree string: UI treats only'active'as active; arbitrary server value renders as archived-style badge without crashing (OQ-2).
3. Functional — hierarchy & ordering
-
Grades list ordered by
displayOrderasc, default 0 first (grade.service.ts:37-39); two grades with same order → stable (insertion) order, no client re-sort. -
Years list default
createdAt:-1(newest first) (academic-year.service.ts:45-47). -
Classes/sections/subjects keep insertion order (
class.service.ts:41-53,section.service.ts:41-48,subject.service.ts:35-47). -
GET /classes/by-year/:idreturns only that year's classes;GET /sections/by-grade/:idonly that grade's sections (server-scoped). -
sort=-createdAtquery param maps to desc (prefix-handling,academic-year.service.ts:45-47);sort=displayOrderon grades works. -
Pagination:
limitclamped 1–100 (400 outside),page≥ 1; metatotalPages/hasNext/hasPreviouscorrect at boundaries (page 1 of 1, last page, empty list →totalPages:1viaMath.max(1,…),pagination-query.dto.ts:46). -
qparam sent → server ignores it; list returns unfiltered (OQ-7) — client search is local; verify no error thrown.
4. Functional — delete & cascade behaviour
-
Delete (any entity) = soft delete: row disappears from lists; reappears if
isDeletedfilter changes (platform admin view,base.repository.ts:20-30) — client never relies on permanence. - Delete grade with live sections/classes → no server error, no cascade (OQ-3); orphan sections/classes still appear under other views; client orphan banners fire.
- Delete class → timetable/attendance rows referencing it still exist server-side (OQ-3); client re-resolves and shows "class no longer exists".
-
Delete assignment → 404 on second delete ("Assignment not found.",
subject-assignment.service.ts:34) → UI treats as removed. -
versionincrements on every update (base.repository.ts:63) — verify via doc read (no client optimistic concurrency).
5. Functional — referential & tenant isolation
-
Class create with non-existent
academicYearId/gradeId/sectionId→ server accepts (no ref checks,class.service.ts:16-18) — client pickers prevent, but a pasted bad ObjectId is still renderable as "Unknown". -
tenantIdnever sent in body/query; cross-tenant ID returns 404 (scoped filter,base.repository.ts:38-40) — verify with two tenants. -
Platform admin sees all tenants' data only via admin bypass (
base.repository.ts:21-23) — client never renders this path. - All 6 controllers require valid JWT (401 without); no RBAC — any authed role can create/update/delete (OQ-1) — QA must confirm with a student token: writes currently succeed (document as known gap).
-
class.campusIdaccepts any ObjectId (ref typoClass,class.schema.ts:12-13, OQ-6) — client hides field;roomIdfree string round-trips.
6. Visual / layout
- Dark-light parity across all lists, explorer, matrix sheet (no hardcoded colors).
- Current-year row distinct (primaryContainer) in both themes; status badges legible.
-
Chips wrap at 200% text scale;
SectionChips"+N more" overflow works. - Long subject names + codes truncate with ellipsis, never overflow the roster table.
- Empty states per entity with correct primary CTA.
7. Accessibility (00-shared/09)
-
TalkBack: explorer tree fully operable (expand/collapse announced,
Semantics(expanded:)); cascading pickers announce enabled/disabled state. - VoiceOver separately.
-
Keyboard-only desktop: Tab order Year→Grade→Section→Class→Subject;
N,Ctrl+Enter,Escwork; focus ring visible. - Conflict banners in live region; snackbars announced.
-
Contrast: body 4.5:1; conflict
tertiaryContainer3:1 for borders/dots. - Reduced motion: no slide/scale; tree animates as fades only.
8. Performance
- 12 grades × 4 sections × 40 classes: list first frame ≤ 200 ms skeleton → content ≤ 2 s (00-shared/10 §1).
- Explorer with 200 nodes: expand/collapse no jank (≤ 50 open nodes policy, 07 §1); children lazy-loaded.
- Reference cache load-once: navigating between 6 list screens triggers ≤ 5 collection fetches total (then cache hits).
-
Infinite scroll no duplicate requests;
hasNext:falsestops loader. - Memory stable over 20-min session with roster cache LRU 20 (13 §1).
9. Offline & network (00-shared/10 §2)
- Offline: all writes blocked + banner; reads render from 24 h cache (with stale badge "Offline — data from HH:mm").
- 24 h TTL expiry → revalidate on next foreground; failure → keep stale + banner.
- Slow network × 429 countdown correct; no auto-retry on 429.
-
5xx on create → generic +
requestId, form preserved, retry safe (idempotency not guaranteed — 409 tells the truth on retry).
10. Tablet / desktop
- Master-detail at ≥ 840 dp: list left, detail right; back button pops branch not shell.
-
RosterTablesticky header, column sorting is disabled (server order preserved). - Right-click = context menu; hover tooltips on conflict icons (500 ms).
- Scrollbar always visible.
11. Localization
- All strings i18n; server 409 messages shown as-is (not translated) for debug accuracy — policy decision: translate or quote server copy verbatim (recommend verbatim + prefix icon).
-
Dates locale-formatted (
dd MMM yyyy) but submitted as ISOYYYY-MM-DD(IsDateString,create-academic-year.dto.ts:9-15). - RTL: chips, tree indents, roster columns mirror.
12. Dark mode / animations / security
- Full visual pass; conflict/error surfaces distinct in dark (11 §6).
- Motion reduced-motion pass; no flash on set-current badge swap.
-
No IDs/tenant data in analytics events; screenshots blur class/student names
(proposed). -
Error screens never leak stack traces; only
error.code+requestIdshown.
13. Server-backed edge mirrors
- 409 paths: year name, grade name, subject code (create) — exact server copy asserted in widget tests.
-
404 paths: every
GET/:id,DELETE/:id,set-currentwith stale id → treated as removed/refreshed, never a stuck screen. -
Year with
endDate < startDate→ server accepts (no check, OQ-5) — client blocks before submit. - Subject marks cross-field violations → server accepts (OQ-5) — client blocks before submit; verify server accepts direct API call (documented gap).
QA scripts
- Golden per component (
HierarchyTree,SectionChips,SubjectAssignmentRow,YearStatusBadge,CascadingEntityPicker) light/dark × phone/tablet/desktop. - E2E (device-cloud P0): fresh tenant → create year → set current → create grades 1–12 → sections A–D → classes → create subjects → assign 3 teachers → verify timetable/attendance screens reflect the new class.
- E2E conflict drill: create duplicate class + duplicate assignment via raw API, verify client banners + no crash.
- E2E multi-device: set-current from device B while device A is on years list → A corrects badge on focus.
- Security smoke: student-token write attempt (documents OQ-1 gap); cross-tenant ID read → 404.
15 — Flutter Implementation Guide (Academics Module)
- 1. Folder structure
- 2. Dependencies
- 3. Cubits
- 4. Repositories + the tree join
- 5. Extensions / joiners (shared
00-shared/11 §8+ module) - 6. Navigation
- 7. Theme
- 8. Localization keys
- 9. Storage
- 10. Testing
- 11. Performance
- 12. Proposals flagged to the team
How to build the Academics feature in the Flutter client on top of 00-shared/11. Forward-looking spec; no client repo exists yet. All DTO/API facts are exact from
src/modules/academics/**(12_API_Mapping.md).
1. Folder structure
features/academics/
├── domain/
│ ├── models/
│ │ ├── academic_year.dart # id, name, startDate, endDate, status, isCurrent
│ │ ├── grade.dart # id, academicYearId?, name, code?, displayOrder, status
│ │ ├── section.dart # id, gradeId, name, capacity, classTeacherId?, roomId?, status
│ │ ├── school_class.dart # id, academicYearId, campusId?, gradeId, sectionId,
│ │ │ # name, capacity, classTeacherId?, roomId?, status
│ │ ├── subject.dart # id, code, name, shortName?, credits, maximumMarks,
│ │ │ # passingMarks, theoryMarks, practicalMarks, status
│ │ └── subject_assignment.dart # id, teacherId, subjectId, classId, academicYearId
│ └── exceptions/academics_exceptions.dart
├── data/
│ ├── dto/
│ │ ├── create_academic_year_dto.dart … create_subject_dto.dart
│ │ ├── update_*_dto.dart (5 entities)
│ │ └── create_subject_assignment_dto.dart
│ └── repositories/
│ ├── academic_year_repository.dart
│ ├── grade_repository.dart
│ ├── section_repository.dart
│ ├── class_repository.dart
│ ├── subject_repository.dart
│ ├── subject_assignment_repository.dart
│ └── reference_repository.dart # aggregated cache loader
└── presentation/
├── cubit/
│ ├── reference_cubit.dart # §2 (13_State_Management §3)
│ ├── cascading_picker_cubit.dart
│ ├── year_list_cubit.dart, grade_list_cubit.dart, class_list_cubit.dart,
│ │ section_list_cubit.dart, subject_list_cubit.dart
│ ├── class_detail_cubit.dart, assignment_matrix_cubit.dart,
│ │ roster_cubit.dart, explorer_cubit.dart
│ └── year_form_cubit.dart, grade_form_cubit.dart, section_form_cubit.dart,
│ class_form_cubit.dart, subject_form_cubit.dart
├── pages/
│ ├── year_list_page.dart, year_detail_page.dart
│ ├── grade_list_page.dart, grade_detail_page.dart
│ ├── class_list_page.dart, class_detail_page.dart, class_assign_page.dart
│ ├── section_list_page.dart, section_detail_page.dart
│ ├── subject_list_page.dart, subject_detail_page.dart
│ ├── explorer_page.dart, teacher_roster_page.dart
│ └── forms/ (year_form_page, grade_form_page, section_form_page,
│ class_form_page, subject_form_page)
└── widgets/
├── hierarchy_tree.dart, section_chips.dart, subject_assignment_row.dart,
│ year_status_badge.dart, entity_status_badge.dart,
│ cascading_entity_picker.dart, marks_summary_tiles.dart,
│ roster_table.dart, conflict_banner.dart, reference_cache_provider.dart
└── joiners.dart # label resolution (§5)
2. Dependencies
flutter_bloc, dio (AppDio with refresh/error interceptors, 00-shared/11 §5),
go_router, get_it, hive (reference cache persistence) or SharedPreferences +
in-memory index, intl, collection (groupBy). No tree package — HierarchyTree
is a custom AnimatedSize list (07 §1).
3. Cubits
ReferenceCubit: singleton per tenant (recreated on tenant switch,13 §10); state{years, grades, sections, classes, subjects, selectedYearId, ttl}; emitsReferenceChangedon any write/refresh (13 §5).- List cubits mix
PaginationCubit(00-shared/06 §3.2); year-scoped variants holdMap<String, PageState>per year. - Form cubits: single
Submit(dto); map 409 →duplicatestate with server message; 400 → field errors fromerror.detailskeys (DTO names). AssignmentMatrixCubit:Addguards against already-assigned pairs using the loaded roster (client-only guard, OQ-4);Replace=Remove+Addsequence.ExplorerCubit: builds the tree fromReferenceCubitcache; never refetches per node (no tree endpoint).
4. Repositories + the tree join
class ReferenceRepository {
// single-flight per collection; caches pages until TTL (24 h)
Future<List<AcademicYear>> years(); // GET /academic-years (all pages)
Future<List<Grade>> grades(); // GET /grades (all pages)
Future<List<Section>> sections({String? gradeId}); // GET /sections[/by-grade/:id]
Future<List<SchoolClass>> classes({String? academicYearId}); // GET /classes[/by-year/:id]
Future<List<Subject>> subjects(); // GET /subjects (all pages)
Future<List<SubjectAssignment>> rosterByClass(String classId, String yearId); // by-class
Future<List<SubjectAssignment>> rosterByTeacher(String teacherId, String yearId); // by-teacher
}
Hierarchy join (no server tree): build nested nodes from cached lists:
- classes →
schoolClass.gradeId→ grade;schoolClass.sectionId→ section; grades optionally bound tograde.academicYearId(floaters shown in all years,grade.schema.ts:9-10); classes grouped under their year viaclass.academicYearId. - Each class leaf's subject count = roster cache
[classId]length (fetched lazily per expanded class,by-class).
5. Extensions / joiners (shared 00-shared/11 §8 + module)
Grade.section(sections)→ label;SchoolClass.labels(grades, sections, years)→(gradeName, sectionName, yearName)for chips — never guessed server-side.SubjectAssignment.classLabel()/.subjectLabel()resolution; missing ref → "Unknown (deleted)" chip (OQ-3 orphan handling).String.toTitle()for section names;String.upperCode()for subject codes.DateTime.toIsoDate()(submitYYYY-MM-DDonly —IsDateString,create-academic-year.dto.ts:9-15);DateTime.fromIso()for display.YearStatusBadgemappingstatusenum (academic-year.schema.ts:7-11).- Numerals:
FontFeature.tabularFigures()on capacity/marks/orders.
6. Navigation
go_router GoRoutes per 04 §3 (prefix /academics); route guards
authGuard + role check (00-shared/05 §9); academics.* permission guard
(planned) — OQ-1. Year switcher is a shell-level control bound to
ReferenceCubit.selectedYearId, not per-page state. Deep links:
/academics/classes/:id, /academics/explore?yearId= (10 §7).
7. Theme
Global AppTheme unchanged; module adds no tokens. Patterns from
11_Design_System_Mapping.md: conflict tertiaryContainer, current primaryContainer,
status badge maps.
8. Localization keys
academics.*: years.*, grades.*, sections.*, classes.*, subjects.*,
assignments.*, explorer.*, forms.*, conflicts.*. Server business messages
(409 copies) rendered verbatim via a serverMessage key with fallback to the raw
message (00-shared/09 policy in 14 §11).
9. Storage
- Reference cache →
Hiveboxesacademics.years/grades/sections/classes/subjects(non-sensitive,00-shared/11 §11), TTL 24 h (13 §3). - No secure-storage usage in this module (no tokens/secrets).
10. Testing
- Unit: cascade picker transitions; pagination per-year maps; duplicate
pre-checks (year/grade/subject code); marks cross-validation
(
theory+practical ≤ maximum,passing ≤ maximum— OQ-5); DTO → model mappers. - Widget: class form cascade disabled-states; roster conflict banner;
explorer expand ≤ 50 nodes;
SectionChipsoverflow. - Golden:
HierarchyTree,SectionChips,SubjectAssignmentRow,YearStatusBadge,CascadingEntityPickerlight/dark × 3 sizes. - Integration: mock-server flows per
14 §QA scripts. - E2E (P0): fresh tenant full ladder setup → timetable/attendance reflect new class; multi-device set-current correction.
11. Performance
- Cache-first render: screens paint from Hive before network revalidation (no blank spinners on warm start).
HierarchyTree: lazy children, ≤ 50 expanded nodes,ListView.builder(07 §1).- Roster rows cached per class (LRU 20,
13 §1);by-classfetched on class detail open only. constconstructors; no whole-page rebuild on filter chips (scopedBlocBuilders).- Pagination: infinite scroll fetches next page only when
hasNext(12 §9).
12. Proposals flagged to the team
- RBAC first (OQ-1): today any authenticated user can write structure —
ship UI gating + flag backend perms (
academics.read/.create/.update/.deleteper entity) before student-facing read mode goes live. - When server adds duplicate/conflict validation (OQ-4), remove client-only guards and render server 409/422 messages instead.
- When
qsearch lands server-side (OQ-7), swap local filters for query params. - When campus/room modules land (OQ-6), replace hidden
campusIdfield + freeroomIdtext with real pickers. - Coaching extension (
docs/IMPLEMENTATION_PLAN.md:314-317,402-429): sections → batches,subjectCategory/classType/batchId— gate behindFeatureFlagsCubit; models gain optional fields, UI unchanged until enabled. - Analytics wiring waits shared
AnalyticsServiceinterface (00-shared/10 §8).
01 — Product Overview (Attendance Module)
- 1. Purpose
- 2. Business goals
- 3. User goals
- 4. Stakeholders
- 5. Why this exists
- 6. Scope boundaries (exact, from code)
- 7. Success metrics
- 8. Edge cases (backend-derived)
- 9. Assumptions (module)
- 10. Open questions (module; global ledger in 00-shared/12)
- 11. Glossary (this module)
StudyLyon — multi-tenant ERP / School Management API. This package designs the Attendance module client (Flutter, forward-looking spec) against the implemented NestJS backend. Every endpoint, DTO field, schema, index, event, worker, and permission is derived directly from
src/modules/attendance/**,src/modules/biometric/**,src/infrastructure/workers/attendance.worker.ts,src/infrastructure/bullmq/**,src/modules/scheduler/**,src/modules/reports/**, andsrc/modules/rbac/**. No feature is invented; gaps are flagged in Assumptions & Open Questions (§10) and marked(planned)/(forward-looking)/(proposed)inline.
1. Purpose
Attendance records one status per student per day — the daily truth a school runs on: who came, who didn't, who was late, who took leave. It is captured by teachers in the classroom (manual marking), ingested from biometric devices (raw punch logs), corrected after the fact (edit), and consumed everywhere: monthly reports, dashboards, absentee alerting, and parent/student visibility.
The domain is deliberately small: one attendance document = one student, one date,
one status (attendance.schema.ts:24-55), enforced by a unique index
(attendance.schema.ts:59-62).
| Responsibility | Source |
|---|---|
| Mark one student's attendance for a date (idempotent upsert) | attendance.service.ts:24-46; attendance.repository.ts:17-37 |
| Batch-mark many students at once | attendance.service.ts:48-54 (bulkMark) |
| Read class roster status for a date | attendance.service.ts:56-61 |
| Read a student's history (optional date range) | attendance.service.ts:63-73 |
| Read a single record / correct it (edit) | attendance.service.ts:75-79, 81-101 |
| Class summary over a date range (per-status counts) | attendance.service.ts:103-113 |
Emit AttendanceMarked / AttendanceUpdated domain events | attendance.service.ts:37-44, 88-99 |
Queue events → BullMQ attendance-process worker | event-queue-map.ts:14-21; attendance.worker.ts:16 |
| Absentee watch (≥3 absences → alert log) | attendance.worker.ts:69-80 |
| Biometric punch ingestion (raw logs) | biometric.controller.ts:14-18; biometric.service.ts:14-16 |
| Attendance summary report (async job) | reports.service.ts:110-137; report-job.schema.ts:8-9 |
| Daily attendance report schedule (07:00 UTC) | scheduler.service.ts:99-104; attendance-report.job.ts:13-24 |
| Dashboard attendance KPI (today, present %) | dashboard.service.ts:28-65 |
2. Business goals
| Goal | Measure |
|---|---|
| Classroom marking is fast | Single tap-cycle status per student; batch covers the whole class in one request (mark-attendance.dto.ts:51-54) |
| No double records | Unique index {tenantId, studentId, date} (attendance.schema.ts:59-62) + upsert findOrCreate (attendance.repository.ts:17-37) |
| Corrections are auditable | AttendanceUpdated event carries the changed field keys (attendance.service.ts:94-98); every doc has createdBy/updatedBy/version (base.schema.ts:13-31) |
| Absentee parents get alerted | AttendanceMarked → attendance-process queue → ≥3-absence alert path (attendance.worker.ts:69-80) — currently log-only, dispatch (planned) (OQ-1) |
| Reports without blocking the API | Async report-generate queue + report_jobs status polling (reports.controller.ts:14-24) |
| Multi-tenant isolation | Every query tenant-scoped by BaseRepository.scopedFilter (base.repository.ts:20-30); cross-tenant IDs → 404 |
3. User goals
- Teacher: mark a whole class in under a minute (bulk), fix a wrong status fast (PATCH), see today's marked/unmarked at a glance, never lose work on a slow connection.
- Org admin: monthly attendance rates per class, drill into per-student history,
ensure devices are pushing punches (
biometric-device.schema.ts:7-11). - Parent: get alerted the moment a child is marked absent, view the child's month at a glance.
- Student: see own attendance % and history (
END_TO_END_USER_FLOWS.md:324-325). - Biometric operator: ingest device punches, verify device health, understand what becomes attendance (and what currently doesn't — see OQ-3).
4. Stakeholders
Teachers (primary marker), class teachers (roster ownership), org admins (reports and policy), parents/students (consumers), biometric device operators (integration), scheduler (jobs), support staff (correction disputes), QA/design/engineering.
5. Why this exists
Attendance is the highest-frequency daily data-entry task in a school and the basis of report cards, fee policy, and parent trust. The backend makes the write path idempotent and cheap; the client's job is to make the capture surface so fast that teachers actually use it, and the consumption surfaces so clear that parents trust it.
6. Scope boundaries (exact, from code)
In scope today (backend): single mark, bulk mark (sequential loop over mark(),
attendance.service.ts:48-54), class-by-date read, student history read, summary
counts, PATCH edit, biometric log ingest (store-only), async attendance summary report,
dashboard KPI, scheduler cron registration, absentee watch (log-only).
Not implemented (flag labels used below):
- Session-based attendance (
sessionId,attendanceMode: 'session') —(planned), schema additions sketched indocs/IMPLEMENTATION_PLAN.md:432-468; the live schema has no session field (attendance.schema.ts). POST /attendance/importbulk import endpoint —(planned); blueprint04-Modules/Attendance.md:30lists it; only the worker'sbulk-importbranch exists (attendance.worker.ts:49,83-102), and it creates records directly with per-record error swallowing (duplicate keys fail silently — OQ-2).- Biometric logs → attendance derivation —
(planned)/(forward-looking);biometric-syncqueue +*/15 * * * *cron exist (scheduler.service.ts:71-76,queue.constants.ts:7) but no worker consumes it;biometric.service.ts:14-16only writes the log. BlueprintRELATIONSHIPS.md:100intendsbiometric_logs.processed → attendance. - Absentee notifications to parents —
(planned);attendance.worker.ts:69-80only logs "alert recommended". PLAN.md row 5.4 describes the target (AttendanceMarked→in-app+pushqueues). - Push/QR device flows —
(forward-looking)(shared ledger B3/B4: no device-token registry, no QR backend). - Client-side analytics —
(proposed)per 00-shared/10_QA_Baseline.md §8. - RBAC enforcement — permissions
attendance.mark/attendance.editare defined (permissions.constants.ts:29-30) and seeded (role.schema.ts:31), global guards areRateLimitGuard → JwtAuthGuard → RbacGuard(app.module.ts:129-131), but the attendance controller declares no@Permissions()decorator (attendance.controller.ts:19-23) — treat 403 as expected contract (OQ-5). - Pagination on attendance list endpoints — not implemented; reads return plain arrays
without
meta(OQ-4, see 12_API_Mapping.md).
7. Success metrics
- Teacher marks a 40-student class in < 60 s (bulk request ≤ 1 payload).
- 100% of mark attempts succeed or surface a clear error — the upsert means retries are
safe (idempotent,
attendance.repository.ts:23-29). - Zero duplicate attendance docs per tenant (unique index enforcement, plus client dedupe in offline queue).
- Correction latency: PATCH round-trip < 500 ms p95; conflict-free because PATCH replaces
only sent fields (
update-attendance.dto.ts:4-22+$set,base.repository.ts:57-66). - Attendance report generated async without request timeout (
reports.service.ts:28-44).
8. Edge cases (backend-derived)
- Remark same student+date → overwrite (upsert), source stays manual unless supplied;
classIdon an existing doc is not updated by remark (attendance.repository.ts:23-29). - Same student marked for a different class on the same date → still one doc (unique on
studentId+date), originalclassIdwins — client must prevent cross-class remarking. - Invalid status on POST → 400
VALIDATION_ERROR(IsEnum,mark-attendance.dto.ts:27-28; e2ep1-operations.e2e-spec.ts:222-228). - Invalid status on PATCH → not enum-validated (
update-attendance.dto.ts:8isIsStringonly) — server may persist a bad status via$set(no runValidators); client must constrain the picker (OQ-6). - Absentee count in the worker counts all absent records for the student, not
consecutive days within a window (
attendance.worker.ts:70-74) — UI must not promise "3 consecutive absences" semantics (OQ-1). - Duplicate records in
bulk-import→ per-record catch → logged warn, not failed (attendance.worker.ts:91-101). dateis sent asYYYY-MM-DDstring and stored asDate(mark-attendance.dto.ts:21;attendance.schema.ts:31-32) — timezone discipline is a client responsibility (see 14_QA_Checklist.md §timezone).- Deleted/cross-tenant record → 404 "Attendance record not found." (
attendance.service.ts:77). - Summary with no records →
{total: 0, summary: {}}(attendance.service.ts:112).
9. Assumptions (module)
- Client is forward-looking (see shared ledger A1 — PRD Phase 1 is backend; these docs specify the full Flutter client per user instruction).
- One attendance per student per day is the only current model; "per session" is
(planned)and the client's calendar/history must not assume per-period rows. - Teacher role carries
['student.read','attendance.mark','attendance.edit'](role.schema.ts:31); admin has everything (role.schema.ts:23). Parent and student roles have no attendance permission today (role.schema.ts:55,63) — the student profile and parent views must be built againststudent.read+ the routes those roles reach (see 12_API_Mapping.md §9, OQ-7). - Enrollment is the roster source:
Student.classId/sectionId(student.schema.ts:38-39) andclass_enrollments(class-enrollment.schema.ts:14-38); the marking grid renders students of one class. - Retry safety: because mark is an upsert, a re-sent offline batch is harmless
(repeated sends converge), but events fire per mark — network-layer dedupe is still
recommended to avoid duplicate
AttendanceMarkedevents.
10. Open questions (module; global ledger in 00-shared/12)
| # | Item | Impact |
|---|---|---|
| OQ-1 | Absentee worker counts all absences (not consecutive, no window) and only logs "alert recommended" — no notification is dispatched. When does the real parent alert land, and what are its semantics? | Alert copy, "consecutive vs total" messaging |
| OQ-2 | bulk-import worker swallows per-record failures (duplicate keys). Should failures surface in a result report for the importing admin? | Import UX, partial-success UI |
| OQ-3 | biometric-sync queue + 15-min cron exist but no worker consumes them; logs never become attendance docs. When is the punch→attendance derivation built? | Device status screen promises |
| OQ-4 | Attendance reads return unpaginated arrays (a 60-student day is fine, but a term of history is not). Is pagination added? | History screen strategy (client-side paging fallback) |
| OQ-5 | No per-endpoint RBAC decorators on attendance; global RbacGuard present (app.module.ts:131) but no permission metadata on the controller. Confirm the server contract for 403. | Permission-gated UI visibility |
| OQ-6 | PATCH status is a free string (no IsEnum) and findOneAndUpdate runs without runValidators — a bad status may persist. Server fix or client-only constraint? | Status picker validation strategy |
| OQ-7 | Parent/student roles have no attendance permissions; yet user flows (END_TO_END_USER_FLOWS.md:324-325,378-379) show students/parents reading attendance. Which permission should gate read-only history? | Parent/student screen gating |
| OQ-8 | getSummary and reports aggregate in memory (no aggregation pipeline) — a full term × all students could be slow. Watch for latency at scale (INDEXING.md:67,93 notes sharding candidacy). | Report UX, loading states |
11. Glossary (this module)
| Term | Meaning |
|---|---|
| Attendance doc | One row: studentId + classId + date + status (+ checkIn/checkOut/source/remarks) |
| Status | present, absent, late, half_day, leave, holiday (attendance.schema.ts:7-14) |
| Source | manual, biometric, import, api (attendance.schema.ts:16-21) |
| Mark | POST create/upsert of one day's record |
| Remark / Overwrite | Same student+date POST again → fields overwritten in place |
| Bulk | POST /attendance/bulk — sequential marks for many records (attendance.service.ts:48-54) |
| Punch | Raw biometric_logs row (student, device, timestamp) — not yet attendance |
| Roster | Students of a class (via student.classId) rendered for marking |
| Summary | {total, summary:{status:count}} from GET /attendance/summary |
| Attendance report | Async attendance_summary report job (report-job.schema.ts:9) |
| Envelope | {success,message,data,meta?,timestamp,requestId} (shared 00-shared/07) |
02 — User Personas (Attendance Module)
- 1. Teacher — "the marker"
- 2. Organization Admin — "the overseer"
- 3. Parent — "the watcher"
- 4. Student — "the self-checker"
- 5. Biometric Operator — "the integrator"
- Persona matrix → screens (map to 05_Screen_Inventory.md)
Five personas that drive the attendance surface. Every claim derives from
src/modules/**— roles/permissions fromrole.schema.ts:8-65andpermissions.constants.ts:29-30,39-43; marking model fromattendance.schema.tsandattendance.service.ts; alerting/reporting fromattendance.worker.tsandreports.service.ts.
1. Teacher — "the marker"
"I need the whole class done before the bell stops ringing."
| Attribute | Value |
|---|---|
| Role | teacher — ['student.read','attendance.mark','attendance.edit'] (role.schema.ts:31) |
| Frequency | 2–8 times/day, per class period, peak at class start |
| Context | Standing, tablet/phone in hand, 40 students, < 60 s per class |
| Pain | Double-entry, slow grids, losing work to a bad connection |
| Success | Bulk mark in one tap; late students caught without re-marking everyone |
| Sources | mark-attendance.dto.ts:11-49; attendance.service.ts:48-54 (bulk = one request) |
Needs: default-present grid (fast path), tap-cycle to change a row, batch ops, undo for mistaps, offline queue that flushes when signal returns, PATCH for corrections later.
2. Organization Admin — "the overseer"
"Is attendance actually being taken, and what does the month look like?"
| Attribute | Value |
|---|---|
| Role | org_admin — ALL_PERMISSIONS incl. attendance.mark/edit, report.generate/read, biometric.* (role.schema.ts:23; permissions.constants.ts:29-30,39-43) |
| Frequency | Daily glance; weekly/monthly deep-dives; end-of-term |
| Pain | No visibility into unmarked classes; slow aggregations |
| Success | Summary counts per status (attendance.service.ts:103-113), monthly report job (reports.service.ts:110-137), device health |
| Sources | GET /attendance/summary; POST /reports/generate; dashboard.service.ts:54-65 |
Needs: today's completion % (marked vs roster), month heatmap by class, per-student drill down, device ingest status (what's coming in from biometric).
3. Parent — "the watcher"
"Tell me the moment my child is marked absent — and show me the month."
| Attribute | Value |
|---|---|
| Role | parent — ['student.read'] (role.schema.ts:55) — no attendance permission today (OQ-7) |
| Frequency | Daily at dismissal; on alert; weekly review |
| Pain | Finding out about absences days later; no context for a lone "absent" |
| Success | Absent alert the same day; month strip showing pattern (absences/lates cluster?) |
| Sources | Alert path: attendance.worker.ts:69-80 (log-only today, OQ-1); history read: attendance.service.ts:63-73 |
| Semantics | "3 absences → alert recommended" counts all absences, not consecutive days (attendance.worker.ts:70-74) |
Needs: child selector, month calendar heatmap, alert inbox item that deep-links to the day, "3 absences this month" honesty (never promise "consecutive" semantics — OQ-1).
4. Student — "the self-checker"
"What's my attendance %? I need to know before the report card."
| Attribute | Value |
|---|---|
| Role | student — [] permissions (role.schema.ts:63) — read-only via student.read surface today (OQ-7) |
| Frequency | Weekly; before exams (attendance % often gates exams) |
| Pain | No per-status breakdown; "present" vs "late" impact on % unclear |
| Success | Own history: GET /attendance/student/:studentId (END_TO_END_USER_FLOWS.md:324-325,348) + summary derived client-side |
| Sources | attendance.service.ts:63-73; report-job report card includes attendance indirectly (reports.service.ts:85-108) |
Needs: % present, month heatmap, status legend, absence reasons they can raise with the teacher.
5. Biometric Operator — "the integrator"
"Are punches landing, and are they turning into attendance?"
| Attribute | Value |
|---|---|
| Role | Admin-adjacent staff with biometric.log.create/read, biometric.device.manage (permissions.constants.ts:41-43) |
| Frequency | Setup; daily health check; incident response |
| Pain | Devices silently failing; punches stored but never becoming attendance |
| Success | Device status (active/inactive/offline, biometric-device.schema.ts:7-11), ingest count today, clear "logs only" vs "derived" pipeline status |
| Sources | POST /biometric/ingest (biometric.controller.ts:14-18); biometric_logs schema (biometric-log.schema.ts:7-27); biometric-sync cron */15 (scheduler.service.ts:71-76) |
| Gap | No worker consumes biometric-sync today — derivation (planned) (OQ-3); UI must show "captured but not yet applied" |
Needs: device list w/ last-punch time, ingest trend, explicit badge "not yet applied to attendance" while OQ-3 is unresolved.
Persona matrix → screens (map to 05_Screen_Inventory.md)
| Persona | Primary screens | Permissions needed |
|---|---|---|
| Teacher | S1 Today's roster, S2 Marking grid, S3 Batch edit, S4 History/calendar | attendance.mark, attendance.edit, student.read |
| Org Admin | S4 History, S5 Monthly report, S6 Report detail, S8 Device status | attendance.*, report.*, biometric.* |
| Parent | S7 Student profile (child), alert deep-link | student.read (OQ-7) |
| Student | S7 Student profile (self) | student.read (OQ-7) |
| Operator | S8 Device status | biometric.device.manage, biometric.log.read |
03 — User Journeys (Attendance Module)
- 1. Mark today's class (teacher, happy path)
- 2. Batch update (whole class in one request)
- 3. Mark-late workflow (late + check-in time)
- 4. View monthly report (admin / teacher)
- 5. Parent absentee alert
- 6. Biometric-captured flow
- Journey → screen/component map
Six end-to-end journeys. All API calls are exact (see 12_API_Mapping.md); flags
(planned)/(forward-looking)mark backend gaps (see 01_Product_Overview.md §10).
1. Mark today's class (teacher, happy path)
sequenceDiagram
actor T as Teacher
participant G as MarkingGrid (S2)
participant R as RosterCubit
participant A as Attendance API
participant Q as attendance-process queue
participant W as AttendanceWorker
T->>G: open Class → "Mark attendance"
G->>R: load(classId, today)
R->>A: GET /attendance/class/:classId?date=YYYY-MM-DD
A-->>R: 200 {data:[...existing marks]}
R->>G: roster rows (default present) + existing marks overlaid
T->>G: tap student "Aarav" → cycle to absent
T->>G: tap student "Meera" → cycle to late
G->>R: mark(studentId, status) [optimistic toggle]
R->>A: POST /attendance {studentId, classId, date, status}
A-->>R: 201 {data: doc} (upsert; overwrites if already marked)
R->>G: row settled, StatusChip confirmed
A->>Q: AttendanceMarked {studentId, status, date}
Q->>W: process-attendance job
W-->>W: absence watch (log-only today, OQ-1)
Fast path: all-absent sweep → POST /attendance/bulk one payload (attendance.service.ts:48-54).
2. Batch update (whole class in one request)
sequenceDiagram
actor T as Teacher
participant G as MarkingGrid (S2)
participant B as BatchEditSheet (S3)
participant R as RosterCubit
participant A as Attendance API
T->>G: long-press "Sweep to absent" (or select-all)
G->>B: sheet: 34 rows selected, status chips visible
T->>B: tap "absent" on all → "Apply to 34"
B->>R: batchMark([{studentId,classId,date,status}...])
R->>A: POST /attendance/bulk {records:[...]}
A-->>R: 201 {data:[docs]} (sequential marks, one event each)
R->>G: grid settles, absent count banner "34 absent"
Note over B: partial failure → per-record result mapping (OQ-2 pattern)
3. Mark-late workflow (late + check-in time)
sequenceDiagram
actor T as Teacher
participant G as MarkingGrid (S2)
participant S as StatusChip popover
participant R as RosterCubit
participant A as Attendance API
T->>G: student arrives late, row currently present
T->>G: tap row → StatusChip popover
S-->>T: status options + optional checkIn/remarks fields
T->>S: choose "late", checkIn = 09:15 (defaults to now)
S->>R: mark(status=late, checkIn)
R->>A: POST /attendance {studentId,classId,date,status:late,checkIn}
A-->>R: 201 upsert (present → late overwritten in place)
R->>G: chip morphs present→late (m-fast), row tinted warning
Same flow later in the day = PATCH instead: PATCH /attendance/:id {status, checkIn}
(attendance.service.ts:81-101) — emits AttendanceUpdated {changes:[...]}.
4. View monthly report (admin / teacher)
sequenceDiagram
actor U as Admin/Teacher
participant M as MonthlyReport (S5)
participant D as ReportDetail (S6)
participant A as Attendance API
participant J as report-generate queue
participant W as ReportsWorker
U->>M: Reports → Attendance, pick class + month
M->>A: GET /attendance/summary?classId&startDate&endDate
A-->>M: 200 {total, summary:{present:812,absent:34,...}}
M->>A: POST /reports/generate {type:'attendance_summary',...}
A-->>J: job queued {jobId, status:'queued'}
J-->>W: generate job
M->>A: GET /reports/:jobId (poll)
A-->>M: {status:'completed', result:{total, summary, period}}
M->>D: tap a day/student → drill into history
D->>A: GET /attendance/student/:studentId?startDate&endDate
reports.service.ts:28-44,110-137; reports.controller.ts:14-24; report.worker.ts:7.
5. Parent absentee alert
sequenceDiagram
actor T as Teacher
participant A as Attendance API
participant Q as attendance-process queue
participant W as AttendanceWorker
actor P as Parent
T->>A: POST /attendance {status: absent}
A->>Q: AttendanceMarked event
Q->>W: process-attendance
W->>W: count student's absent docs (all-time, not consecutive)
Note over W: >= 3 → "alert recommended" LOG ONLY (attendance.worker.ts:69-80)
Note over P: NO dispatch today. Target (planned, PLAN.md:59): in-app + push
P->>P: future: notification → deep link studylyon://attendance/:date
Honest copy constraint: the app must say "3 absences this month" — the worker counts
all absent records without a window (attendance.worker.ts:70-74), so "consecutive"
must never be claimed (OQ-1).
6. Biometric-captured flow
sequenceDiagram
actor D as Device (operator/forward-looking)
participant B as Biometric API
participant L as biometric_logs
participant Q as biometric-sync queue (cron */15)
participant W as SyncWorker
participant A as Attendance docs
D->>B: POST /biometric/ingest {studentId, deviceId, timestamp, mode?}
B->>L: create raw log (biometric.service.ts:14-16)
Note over B: deviceId must be a registered device (OQ-3 device registry)
Q->>W: biometric-sync repeatable job (scheduler.service.ts:71-76)
Note over W: NO worker consumes biometric-sync today → derivation (planned)
Note over A: target: punches → attendance docs via AttendanceMarked/import
UI implication (S8 Device status): show "N punches captured today — not yet applied" until the pipeline is built (OQ-3).
Journey → screen/component map
| Journey | Screens | Key components |
|---|---|---|
| 1. Mark today | S1, S2 | AttendanceGrid, StatusChip, MarkedCountBar |
| 2. Batch update | S2, S3 | BatchEditSheet, StatusChip, selection model |
| 3. Mark-late | S2 | StatusChip popover, time field |
| 4. Monthly report | S5, S6 | CalendarHeatmap, SummaryDonut, status legend |
| 5. Parent alert | S7 (+ notifications) | AbsenceAlertCard, day deep-link |
| 6. Biometric | S8 | BiometricDeviceTile, ingest trend |
04 — Information Architecture (Attendance Module)
- 1. Position in the app shell
- 2. Attendance IA tree
- 3. Navigation model (per
00-shared/05 §3) - 4. Routing table (module)
- 5. Cross-links (inbound/outbound)
- 6. Modal hierarchy (per
00-shared/05 §5) - 7. Quick actions & FABs
- 8. Role → surface map
Where attendance screens live in the app shell (00-shared/05), what links into them, and what they own. Role gating derives from
role.schema.ts:8-65andpermissions.constants.ts:29-30,39-43; routes usestudylyon://deep-link convention (00-shared/05 §4).
1. Position in the app shell
Attendance is a top-level destination /attendance for teacher and admin
(00-shared/05 §2); default landing for teacher = "Attendance/Today" (00-shared/05 §2).
Parents/students reach attendance through the student profile surface (S7), not the
top-level tab.
AppShell (role-aware, 00-shared/05 §1)
├─ /home Dashboard (admin) / Home (teacher)
├─ /attendance ATTENDANCE MODULE (this doc) — teacher, admin
├─ /students student profile → S7 Attendance tab
├─ /reports admin → S5 Monthly report entry (proposed)
└─ /notifications parent alert deep-link → S7 day view
2. Attendance IA tree
/attendance
├─ S1 Today's roster — my classes → pick class (default today)
│ └─ S2 Marking grid — roster rows × StatusChip
│ ├─ S3 Batch edit sheet — multi-select → one status for N rows
│ └─ StatusChip popover — per-row: 6 statuses + checkIn/checkOut/remarks
├─ S4 History / calendar — pick class, month → heatmap → day detail
├─ S5 Monthly report — class × range → summary + report job (admin)
│ └─ S6 Report detail — per-status breakdown + drill to S4/S7
└─ S8 Biometric device status — devices, last punch, ingest trend (admin/operator)
/students/:id → S7 Student attendance profile tab (self: student, child: parent)
/notifications → absent alert → studylyon://attendance/:date (parent)
Screen numbering matches 05_Screen_Inventory.md.
3. Navigation model (per 00-shared/05 §3)
- Phone:
/attendancestack: S1 → S2 → S3 (sheet, half-height); S4/S5 tabbed or pushed from S1 context menu; S8 pushed from S1 app-bar menu. - Tablet/desktop: master-detail — left: class + month list; right: S2 grid or S4
heatmap (≥ 840 dp,
02_Design_Tokens.md §8). - Tabs within attendance (teacher):
Today(S1) ·History(S4) ·Devices(S8, admin only) —AppTabs(00-shared/03). - Breadcrumbs (desktop only): Attendance › Class › Date.
4. Routing table (module)
| Route | Screen | Guard |
|---|---|---|
/attendance | S1 Today's roster (default today) | attendance.mark (teacher) or student.read+report perms (admin) |
/attendance/class/:classId/date/:date | S2 Marking grid (read/mark) | attendance.mark (read: student.read) |
/attendance/class/:classId/history | S4 History/calendar | attendance.mark / attendance.edit |
/attendance/class/:classId/report/:month | S5 Monthly report | report.generate/report.read (admin) |
/attendance/reports/:jobId | S6 Report detail | report.read |
/attendance/devices | S8 Biometric device status | biometric.device.manage / biometric.log.read |
/students/:id → tab attendance | S7 Student attendance profile | student.read (OQ-7) |
studylyon://attendance/:date | Deep link → S7 day view (parent alert) | student.read (OQ-7) |
Guard model: route guards check user.permissions; server remains authoritative
(00-shared/05 §9, 00-shared/06 §5 — 403 → permission screen or hidden destination).
Note: backend has no per-endpoint RBAC metadata on attendance yet (OQ-5) — client gating
must not promise 403 where server might 200.
5. Cross-links (inbound/outbound)
| From | To | Why |
|---|---|---|
| Home dashboard KPI ("Today: 94% present") | S2 grid for the class | KPI from dashboard.service.ts:54-65 |
| Timetable class card | S2 "Mark attendance" (from docs/user-flows/END_TO_END_USER_FLOWS.md:27) | Daily teaching flow |
| S2 grid | Students module (student profile) | view student, check context |
| S4 heatmap day | S2 grid for that date | day drill-down (read mode) |
| S5 report | S4/S7 | investigate an anomaly |
Notifications (absent alert, (planned)) | S7 day view | parent sees the day |
Organization settings (attendance {gracePeriod,lateThreshold,...}) | S2 late chip behavior (planned) — embedded settings exist (organizations/update-organization-settings.dto.ts:8, organizations/schemas/organization.schema.ts:98) but attendance service does not consume them today (OQ-8) | future late-threshold logic |
6. Modal hierarchy (per 00-shared/05 §5)
- Snackbar — "Marked absent" / "Saved (3)" / undo.
- Bottom sheet — S3 batch edit, StatusChip popover (half-height), date/class pickers.
- Dialog — confirm bulk "Mark 34 as absent?" when selection is large (> 10, or leaving grid with unsaved offline queue); destructive confirms only.
- Full-screen — S6 report detail; S8 device add/edit
(planned). - Loader — only grid refresh when nothing cached; skeletons otherwise.
7. Quick actions & FABs
- FAB on S1/S2: "Mark attendance" (opens S2 for default class/date) — one FAB per screen
(
00-shared/05 §7). - Context menu on S2 row: edit details (checkIn/checkOut/remarks), view student, copy.
- Swipe on S4 day cell: mark-as-present/late quick fix (desktop: right-click).
- Keyboard (desktop):
P/A/L/H/V/Dcycle status on focused row (see10_Interaction_Specification.md).
8. Role → surface map
| Surface | Teacher | Admin | Parent | Student | Operator |
|---|---|---|---|---|---|
| S1/S2 Today's roster + grid | ✅ mark | ✅ | — | — | — |
| S3 Batch edit | ✅ | ✅ | — | — | — |
| S4 History/calendar | ✅ | ✅ | — | — | — |
| S5/S6 Reports | ❌ (report perms not seeded for teacher) | ✅ | — | — | — |
| S7 Student profile | ✅ (own class) | ✅ | ✅ child | ✅ self | — |
| S8 Device status | — | ✅ | — | — | ✅ |
Teacher has no report.* permission (role.schema.ts:31) — S5/S6 are admin-only today
(OQ: confirm if teacher monthly view should be granted report.read).
05 — Screen Inventory (Attendance Module)
- S1 — Today's Roster (class picker)
- S2 — Marking Grid (hero screen, fully speced in 06)
- S3 — Batch Edit Sheet
- S4 — History / Calendar View
- S5 — Monthly Report (summary)
- S6 — Report Detail
- S7 — Student Attendance Profile (tab)
- S8 — Biometric Device Status
Eight screens. Purpose, entry, primary tasks, components, API, and states for each. API refs are exact (see 12_API_Mapping.md); flags:
(planned)= backend gap.
S1 — Today's Roster (class picker)
| Purpose | Pick a class (my classes this academic year) + date (default today) → enter marking. Surfaces completion: "23/40 marked". |
| Entry | /attendance (teacher landing, 00-shared/05 §2); timetable class card ("Mark attendance", END_TO_END_USER_FLOWS.md:27); dashboard KPI tap |
| Primary tasks | pick class, switch date (yesterday corrections), start marking, view unmarked count |
| Data | classes via GET /api/v1/classes (academics module); existing marks need class+date → S2 loads them |
| Components | AppListTile (class cards + completion ring), AppDatePicker, AppSectionHeader ("Marked today"), AppBadge (unmarked count), AppFAB |
| States | skeleton → class list (grouped by grade/section) → empty (no classes assigned) |
| Notes | Teacher's classes = classes where classTeacherId = me (class.schema.ts:28) or timetable assignment — resolve via timetable module (planned); fallback: all classes the user can read |
S2 — Marking Grid (hero screen, fully speced in 06)
| Purpose | Mark/overlay one status per student for a class+date; the fastest daily capture surface. |
| Entry | S1 pick → push; deep-link /attendance/class/:id/date/:date |
| Primary tasks | tap-cycle status, sweep-all-absent, batch apply, mark-late w/ check-in, remark existing day (overwrite semantics), undo |
| Data | GET /attendance/class/:classId?date= (existing docs); roster = students with classId (student.schema.ts:38-39); writes POST /attendance or POST /attendance/bulk |
| Components | AttendanceGrid, StatusChip, MarkedCountBar, StatusFilterChips, BatchEditSheet, UndoBar (see 07) |
| States | loading skeleton → grid (rows default-present, existing marks overlaid) → offline (cached roster + queued marks) |
| Rules | one doc per student/day enforced by unique index attendance.schema.ts:59-62; re-mark = overwrite (attendance.repository.ts:23-29) |
S3 — Batch Edit Sheet
| Purpose | Apply one status to many selected rows in a single POST /attendance/bulk. |
| Entry | S2: long-press row (multi-select) → sheet; or "Sweep all to absent" quick action |
| Primary tasks | multi-select rows, choose target status, confirm, see per-record result |
| Data | BulkMarkAttendanceDto {records[]} (mark-attendance.dto.ts:51-54) → sequential marks, one AttendanceMarked per record (attendance.service.ts:48-54) |
| Components | BatchEditSheet (half-height), status chips grid, selection counter, AppButton "Apply to N" |
| Notes | Partial failure handling per OQ-2 (server returns docs array; failures would throw — client shows snackbar with count; retry-safe because upsert) |
S4 — History / Calendar View
| Purpose | Monthly heatmap per class (or per student from S7): status colors per day, tap day → day summary. |
| Entry | /attendance/class/:id/history; S2 app-bar calendar icon; S6 drill-down |
| Primary tasks | month navigation, status-filter heatmap, day detail, jump to S2 read-only for a date |
| Data | GET /attendance/class/:classId?date= per day (N requests, OQ-4 unpaginated) or GET /attendance/student/:studentId?startDate&endDate for S7 |
| Components | CalendarHeatmap, StatusLegend, DaySummaryCard, AppDatePicker |
| Notes | Class-level month = up to ~30 daily fetches — cache aggressively (see 13_State_Management.md); consider server aggregation (planned) when OQ-4 resolves |
S5 — Monthly Report (summary)
| Purpose | Class attendance stats for a range: totals + per-status counts; trigger async report. |
| Entry | /attendance/class/:id/report/:month (admin) — teacher lacks report.* (role.schema.ts:31, OQ) |
| Primary tasks | pick class + range, view summary, request generated report, open detail |
| Data | GET /attendance/summary?classId&startDate&endDate (attendance.service.ts:103-113 — {total, summary:{status:count}}); POST /reports/generate {type:'attendance_summary'} → GET /reports/:jobId (reports.controller.ts:14-24) |
| Components | SummaryDonut (or AppCharts bar), AppStatTile per status, CalendarHeatmap (rate by week), ReportJobCard (queued/processing/completed/failed — report-job.schema.ts:13-18) |
| Notes | Both summary paths aggregate in memory (attendance.service.ts:103-113, reports.service.ts:110-137) — UI must handle slow responses at scale (OQ-8) |
S6 — Report Detail
| Purpose | Full generated attendance_summary result: totals, period, per-status breakdown; drill into students/days. |
| Entry | S5 report card tap; /attendance/reports/:jobId |
| Primary tasks | read result, filter status, drill to S4/S7, re-run for new range |
| Data | GET /reports/:jobId → {status, result:{total, summary, classId, period}} (reports.service.ts:131-136) |
| Components | AppInfoRow (period, class), AppCharts bar/donut, StatusLegend, AppEmptyState (no data) |
| States | queued → processing (progress) → completed / failed (error from report-job.schema.ts:38-39) |
S7 — Student Attendance Profile (tab)
| Purpose | One student's history: month heatmap, % present, per-status counts, day detail. Used by self (student), child (parent), and class teacher (context). |
| Entry | /students/:id → "Attendance" tab; parent alert deep link studylyon://attendance/:date |
| Primary tasks | month nav, % present, day drill-down, mark/remark (teacher only) |
| Data | GET /attendance/student/:studentId?startDate&endDate (attendance.service.ts:63-73) |
| Components | CalendarHeatmap, AppStatTile (% present, absent, late), StatusLegend, AbsenceAlertCard |
| Notes | Role-gating open (OQ-7): parent/student roles have student.read only (role.schema.ts:55,63); END_TO_END_USER_FLOWS.md:324-325,378-379 assume read access |
S8 — Biometric Device Status
| Purpose | Device health + ingest visibility: status (active/inactive/offline, biometric-device.schema.ts:7-11), last punch, ingest count, pipeline state. |
| Entry | /attendance/devices (admin/operator) |
| Primary tasks | list devices, check last-punch freshness, view today's ingest count, understand derivation status |
| Data | devices via biometric module repository (no list endpoint today — (planned), OQ-3); logs GET-able via biometric.log.read (permissions.constants.ts:42) |
| Components | BiometricDeviceTile, AppBadge (status), IngestTrendChart (proposed), AppBanner ("punches captured — not yet applied", OQ-3) |
| Notes | Ingest exists (POST /biometric/ingest, biometric.controller.ts:14-18); biometric-sync cron registered (scheduler.service.ts:71-76) but no worker → nothing becomes attendance (OQ-3) |
06 — Screen Specifications (Attendance Module)
- S2 — Marking Grid (hero)
- S3 — Batch Edit Sheet (half-height)
- S4 — History / Calendar View
- S7 — Student Attendance Profile (tab in /students/:id)
- S8 — Biometric Device Status
Production-grade specs for the attendance screens. The Marking Grid (S2) is the hero — full treatment first, then the remaining screens. Tokens:
Sp= 00-shared/02 §3 spacing,Ty= typography §2,El= elevation §5,R= radius §4,Mo= motion §6. Components from 00-shared/03 and 07_Component_Library.md.
S2 — Marking Grid (hero)
1. Chrome & layout
AppBar: [back] "Grade 9 – B" headlineMedium [date picker] [history icon]
subtitle bodyMedium: "Mon, 20 Jul 2026 · 23/40 marked" (live)
MarkedCountBar (sticky): 23 ✓ · 2 late · 1 half day · 1 holiday · 13 unmarked [labelMedium chips]
StatusFilterChips (sticky, scrollable): All · Unmarked · Present · Absent · Late · Half day · Leave · Holiday
AttendanceGrid (primary scroll):
header row: Roll No | Student (name + admission no) | Status (chip)
rows 56 dp: [roll] [Avatar 32 + name + rollNumber] [StatusChip] [chevron]
footer: "40 students · saved locally · 3 queued" bodySmall onSurfaceVariant
Batch action bar (appears on selection): [N selected] [Mark present/absent/late] [Clear]
FAB: "Mark all absent" (sweep) — hidden when selection active
Padding Sp 16 phone / Sp 24 tablet+; row height 56 (00-shared/04 §6); toolbar 64.
2. Status toggle semantics (the core interaction)
| Status | Default tap target | Cycle order (tap) | Visual |
|---|---|---|---|
| Present (default) | — | 1st tap → Absent; 2nd → Late; 3rd → back to Present | success tint, check icon |
| Absent | 2nd tap | → Late → Present | error tint, block icon |
| Late | 3rd tap | → Present → Absent | tertiary/warning tint, clock icon |
| Half day | via chip popover only | — | tertiary tint, half-circle |
| Leave | via chip popover only | — | neutral surfaceVariant, vacation icon |
| Holiday | via chip popover only (bulk "mark holiday") | — | neutral surfaceVariant, sun icon |
- Semantics: a row's status = the doc's
statusvalue (attendance.schema.ts:7-14). The chip shows exactly what will be persisted; no "pending" pseudo-status except during in-flight/queued states (see §7). - Default-present model: an unmarked student renders "Present" as the prospective
state (teacher marks by exception). Only explicit taps create docs — unmarked rows
send nothing. This matches upsert semantics: sending
presentwould create docs for everyone; the UI must decide policy (see OQ-9 in01: send-everyone vs send-changed-only). Decision: send-changed-only by default; "Mark all" sweep explicitly sends all. - Re-mark (overwrite): tapping an already-marked row overwrites the doc
(
attendance.repository.ts:23-29—findOrCreateassigns new fields in place). The chip flips withMo.m-fast; the prior status is shown as a transient label ("was: absent") for 2 s — no confirmation dialog for single rows (speed first). - checkIn/checkOut/remarks survive overwrites that don't include them: the upsert
Object.assignonly replaces keys present in the payload (attendance.repository.ts:25-27). The popover edit path must fetch the existing doc first (GET /attendance/:id) to prefill (OQ-6 notes PATCH quirks; for POST upsert, sending partial fields is safe).
3. Bulk selection & batch apply
- Entry: long-press any row → multi-select mode (rows get checkboxes, batch bar
slides up
Mo.m-base); or "select all visible" via header checkbox; or FAB "Mark all absent" (one-tap sweep → confirm sheet). - Selection UX: selection counter "34 selected"
labelLargein batch bar; selected rows tintprimaryContainer; deselect by tap again orEsc(desktop). - Apply flow: batch bar → status chip options →
AppBottomSheet(S3) preview listing first 5 names + "+29 more", current statuses, target status; CTA "Apply to 34". On confirm →POST /attendance/bulk {records:[...]}(mark-attendance.dto.ts:51-54). - Conflict UI (remark warning): if selection includes rows already marked with a
different status (present today), the sheet shows an amber
AppBanner: "14 of 34 are already marked — they will be overwritten." (upsert semantics,attendance.repository.ts:23-29). Confirmation CTA changes to "Overwrite 34". Never silently overwrites without this banner — this is the one dialog in the grid flow. - Cross-class guard: if two selected students share the same
studentId... impossible in one class; but if the same student appears under two class pickers on the same date, the unique index{tenantId, studentId, date}(attendance.schema.ts:59-62) keeps ONE doc; grid shows asurfaceVarianttooltip on first mark of the day (OQ-10: remark keeps originalclassId,attendance.repository.ts:29-33— the row must display the class it was first marked under). - Sweep-all-absent: FAB → sheet "Mark all 40 as absent?" → bulk. Undo available
for 4 s (
UndoBar) — re-sends previous statuses via bulk.
4. Mark-late workflow (row popover)
Tap row's StatusChip (not the row) → StatusChipPopover (half-height sheet):
- Status selector (6 chips, current highlighted).
- Optional:
checkIntime (AppTimePicker, default now,Sp 8label "Check-in"),checkOuttime (optional),remarks(AppTextField). - CTA "Save" → POST upsert with only changed keys.
checkInsent as ISO date-time (mark-attendance.dto.ts:32-33acceptsIsDateString). - Late rows keep the chip at
tertiary; the sheet marks which fields are new vs existing ("checkIn currently 09:12").
5. Grid states
| State | Render |
|---|---|
| Initial load | AppSkeleton list rows (40 × row skeleton, shimmer Mo.m-slow) |
| Loaded | grid; existing marks overlaid from GET /attendance/class/:classId?date=; unmarked = default-present chip (dashed outline to signal "not saved") |
| Mark in-flight | chip shows mini CircularProgressIndicator (16 dp) inside chip; row disabled |
| Mark saved | chip settles + lightImpact haptic; MarkedCountBar animates AnimatedSwitcher |
| Mark error (4xx/5xx) | chip reverts to previous state; row tint errorContainer; AppSnackbar(error) "Couldn't save — retry"; retry button in snackbar |
Conflict (409 DUPLICATE_RESOURCE, 00-shared/06 §5) | snackbar "Already marked — reloading" + grid refresh (server upsert normally prevents this; occurs on stale offline flush) |
| Offline | AppOfflineBanner top; marks go to local queue (chip shows dashed + queue icon); reads from last-good cache |
| Empty class | AppEmptyState "No students in this class" |
| 403/404 | AppErrorState with code; hidden if role-gated earlier |
6. Fast-marking patterns (behaviour hooks)
- Tap cycle (3 states) as §2; long-press row = multi-select, not menu (gesture
priority,
00-shared/08 §1). - Sticky MarkedCountBar always visible — the teacher's "done?" signal.
- Filter chips collapse the grid (e.g., "Unmarked") for quick scanning; filters are client-side (roster already loaded).
- Auto-advance (proposed, off by default): after marking row N's chip, focus moves to row N+1 — power-user toggle in settings.
- Consecutive same-status taps across rows: keep the chip in selection mode after apply ("marked 3 more") — mini-tutorial in empty state.
7. Motion & haptics
- Chip state flip:
Mo.m-fast(150 ms, easeOutCubic) color/size transition +AnimatedSwitchericon morph. - Batch bar slide-up:
Mo.m-base; sheetshowModalBottomSheetdefault. - Sweep confirm dialog:
Mo.m-basescale+fade; destructive tint only when overwriting (not for first-time marks — overwrite is the conflict path, §3). - Haptics: saved
lightImpact; errormediumImpact; long-pressselectionClick(00-shared/08 §3); none on desktop. - Reduced motion:
m-instantfades only (00-shared/08 §4).
8. Accessibility
- Grid = semantic table: header row announces "Roll, Student, Status"; each row announces
"Aarav Sharma, absent — double-tap to change" (
00-shared/09 §7). - Status chips:
Semantics(toggled:)+ label; never color-only — every chip has an icon + text label (00-shared/09 §9,00-shared/09:76). - Live region on MarkedCountBar updates ("23 marked, 17 unmarked").
- Touch targets: chips ≥ 48×48 in grid mode (dense row variant allows 44,
00-shared/02 §9); row ≥ 56 dp. - Focus order: chips left→right per row, then next row;
Escexits selection;Tabenters grid table. - Text scale 2×: chips wrap to two lines; grid stays usable (chips drop to icon+short label "Abs" pattern for a11y density toggle).
9. Responsive & dark/light
- Phone: full-width grid, chips right-aligned, MarkedCountBar horizontal scroll.
- Tablet (≥ 840 dp): master-detail — S4 heatmap left pane, S2 right; grid columns student-name wider.
- Desktop: hover states on chips (
MouseRegion), click = tap, right-click = chip popover, keyboard shortcutsP/A/L/H/V/Don focused row, arrows navigate rows. - Dark mode: chip tints use dark token variants (
errorContainer #8C1D18etc.); dashed "unsaved" chip outlineoutlineVariantvisible in both modes.
S3 — Batch Edit Sheet (half-height)
- Trigger: multi-select → batch bar → status chip. Content: preview list (first 5 + N), current-vs-target statuses, conflict banner (§2.3), CTA "Apply to N" / "Overwrite N".
- Submit:
POST /attendance/bulk→ on 200data:[docs]→ grid reconciles each row bystudentId(docsare full docs with_id,attendance.service.ts:48-54). - Failure: snackbar "9 of 34 failed — retry" (retry re-sends only failed rows; safe — upsert). Server may throw on first bad record (sequential loop) — client should pre-validate all records client-side (valid enum + MongoIds) to avoid partial writes (OQ-2).
S4 — History / Calendar View
- Month header:
‹ July 2026 ›(desktop: arrows; phone: swipe month).CalendarHeatmapcells 40×40 dp (min), colored by day's dominant status; tap cell →DaySummaryCard(counts per status from that day's docs). - Data: per-day
GET /attendance/class/:classId?date=fetches (30 calls/month). Cache strategy in13_State_Management.md §3; month switch reuses cached days. - Unmarked day: neutral cell with "0 marked" tooltip. Future days: disabled.
- Status filter chips above heatmap highlight matching days (e.g., only days with ≥1 absent).
- Drill: cell tap → S2 read-only mode for that date (no FAB, chips disabled unless
attendance.mark).
S7 — Student Attendance Profile (tab in /students/:id)
- Header: student name + class,
AppStatTiles: % present (roundedMath.round-style server formula mirrored: present/totaldashboard.service.ts:57-64), absent count, late count, half-day count for the selected month. CalendarHeatmapmonth; tap day → day detail (status + checkIn/checkOut + remarks + source badge —sourcefrom docattendance.schema.ts:46-51, e.g. "via biometric").- Data:
GET /attendance/student/:studentId?startDate&endDate(returns array, client groups by date; OQ-4 unpaginated). - Teacher with
attendance.mark: day detail offers "Correct status" → PATCH flow (PATCH /attendance/:id,attendance.service.ts:81-101). - Parent: read-only;
AbsenceAlertCard((planned)dispatch, OQ-1) shows when the child has ≥3 absences in the visible window — copy says "3 absences" (never "consecutive",attendance.worker.ts:70-74).
S8 — Biometric Device Status
- Device list:
BiometricDeviceTile— name, model, location, status badge (active/inactive/offline,biometric-device.schema.ts:7-11), last-punch time, today's ingest count (frombiometric_logsbydeviceId+timestamp,biometric-log.schema.ts:26). - Status freshness rule: device
active+ no punch in 24 h → warn chip "no punches". - Pipeline banner (persistent until OQ-3): "Punches are captured — automatic sync to
attendance is
(planned)." — honest aboutbiometric-synccron (scheduler.service.ts:71-76) having no worker. - Add/edit device:
(planned)form (name, deviceId unique per tenantbiometric-device.schema.ts:35-36, model, location, config JSON). - Ingest log viewer: filter by device/student/day; fields
studentId, deviceId, timestamp, mode, rawData(biometric-log.schema.ts:9-23).
07 — Component Library (Attendance Module)
- Status model (shared by all components)
- 1. AttendanceGrid
- 2. StatusChip
- 3. StatusChipPopover (bottom sheet)
- 4. BatchEditSheet
- 5. MarkedCountBar
- 6. StatusFilterChips
- 7. CalendarHeatmap
- 8. DaySummaryCard
- 9. SummaryDonut (S5/S6)
- 10. AbsenceAlertCard (S7,
(planned)dispatch — OQ-1) - 11. BiometricDeviceTile (S8)
- 12. UndoBar
- 13. ReportJobCard (S5/S6)
- Shared-component usage map
Module-specific components; all shared primitives (
AppCard,AppListTile,AppBottomSheet, …) come from 00-shared/03 and are referenced by name. Token references per 00-shared/02; status colors follow the standalone roles (success/warning/error, 02 §1.1).
Status model (shared by all components)
| Status | Token role | Icon (Material Symbols, filled) | Label (i18n key) |
|---|---|---|---|
present | success | check_circle | att.status.present |
absent | error | block | att.status.absent |
late | warning (tertiary) | schedule | att.status.late |
half_day | warning (tertiary) | right_half_circle | att.status.halfDay |
leave | neutral surfaceVariant | beach_access | att.status.leave |
holiday | neutral surfaceVariant | wb_sunny | att.status.holiday |
unmarked | outlineVariant | radio_button_unchecked | att.status.unmarked |
Statuses/enum from attendance.schema.ts:7-14. Never color-only: icon + label always
(00-shared/09 §9).
1. AttendanceGrid
- Purpose: the marking surface — virtualized roster rows with status chips.
- Props:
rows: List<AttendanceRow>(student + current doc),statuses: Map<studentId, Status>,onCycle(row)(tap-cycle),onOpenPopover(row),selection: SelectionState?,enabled(read-only mode),filter(from StatusFilterChips). - Structure: header row (sticky) +
ListView.builderrows (56 dp,itemExtentfixed — perf, see 15_Flutter_Implementation_Guide.md); optional empty state slot. - Semantics: table role (00-shared/09 §7), row summary "Aarav Sharma, absent".
- Golden tests: 3 sizes × light/dark; 60-row build < 8 ms frame budget.
2. StatusChip
- Purpose: single status affordance on a row; tap = cycle (Present→Absent→Late→Present).
- Props:
status,saved(dashed outline when unsaved/queued),dense(44 dp a11y toggle),onTap,loading,selected. - Behaviour:
Mo.m-fastcolor/icon morph; mini spinner when in-flight; long-press → popover (owner: row gesture arbitration). - A11y:
Semantics(toggled: status != unmarked), label = status name + "double-tap to change"; state announced via live region on change.
3. StatusChipPopover (bottom sheet)
- Purpose: full status picker + optional checkIn/checkOut/remarks for one row.
- Props:
row,existingDoc?(prefill,GET /attendance/:id),onSave(patch). - Contents: 6 status chips (grid 3×2),
AppTimePickercheck-in (default now), check-out optional,AppTextFieldremarks,AppButton"Save". - Semantics: sheet title "Mark Aarav Sharma — 20 Jul".
4. BatchEditSheet
- Purpose: apply one status to N selected rows (
POST /attendance/bulk,mark-attendance.dto.ts:51-54). - Props:
selection,conflicts(rows already marked differently),onApply(status). - Contents: preview list (5 rows + "+29 more"), current→target status chips, conflict
AppBanner(amber, "will be overwritten"), CTA "Apply to N" / "Overwrite N". - A11y: counter announced; banner is a live region.
5. MarkedCountBar
- Purpose: sticky live summary of the day ("23 marked · 17 unmarked").
- Props:
counts: Map<Status,int>,total. - Behaviour: animates on change (
AnimatedSwitcher,Mo.m-fast); compact on phone (horizontal scroll). Live region for a11y.
6. StatusFilterChips
- Purpose: client-side filter of grid rows.
- Props:
selected,counts(badge per status),onChanged. - Rules: "All" default; chips show counts as
AppBadge; filtering never refetches (roster in memory).
7. CalendarHeatmap
- Purpose: month grid of day cells colored by status mix; used in S4 (class) and S7 (student).
- Props:
days: Map<date, DayStats>,selectedDate,onDayTap,disabledBefore/future. - Cell: 40×40 min; color = dominant status (ties: worst status wins — absent > late > half_day > leave > present); empty = neutral.
- A11y: each cell label "20 Jul — 28 present, 2 absent"; cells are buttons ≥ 44 dp.
- Perf: cell =
CustomPaint-free plainContainer/InkWell; month builds viaGridView.builder(see 15).
8. DaySummaryCard
- Purpose: per-day counts + drill actions (S4 tap).
- Props:
date,counts,onOpenGrid(S2 read-only),onOpenStudent(S7). - Contents: status legend row with counts, source mix note (manual/biometric/import/api
from
attendance.schema.ts:16-21).
9. SummaryDonut (S5/S6)
- Purpose: per-status share of a range (
GET /attendance/summary→{total, summary:{present:n,...}},attendance.service.ts:103-113). - Props:
summary,total,onStatusTap(filter). - A11y:
Semanticssummary text "Total 812: 750 present, 34 absent, …" (00-shared/09:60); tabular fallback list whensummaryempty (total: 0).
10. AbsenceAlertCard (S7, (planned) dispatch — OQ-1)
- Purpose: surface absence pattern to parents: "3 absences this month".
- Props:
count,window(visible month),onViewDays. - Copy rules: count from worker semantics = total absent docs in window
(
attendance.worker.ts:70-74); never claim "consecutive". Banner tonewarning.
11. BiometricDeviceTile (S8)
- Purpose: device row: health + ingest state.
- Props:
device(name, model, location, status frombiometric-device.schema.ts:15-31),lastPunchAt,todayCount,onTap(detail). - States: status badge (
activesuccess /inactiveneutral /offlineerror); "no punches in 24 h" warn chip; pipelineAppBannerper 06 §S8. - A11y: label "Entrance gate — active — 412 punches today".
12. UndoBar
- Purpose: 4 s undo window after sweep/batch applies (00-shared/03 undo pattern).
- Props:
description,onUndo. - Behaviour: undo re-sends previous statuses via bulk (upsert-safe); expires after 4 s (00-shared/03 §F).
13. ReportJobCard (S5/S6)
- Purpose: async report job state (
report-job.schema.ts:13-18). - Props:
status(queued/processing/completed/failed),type(attendance_summary,report-job.schema.ts:8-9),result?,error?,onOpen. - Behaviour: queued/processing →
LinearProgressIndicator; failed →AppErrorStatecompact with servererrorstring; completed → open S6.
Shared-component usage map
| Needs | Shared component |
|---|---|
| Class picker rows | AppListTile + AppAvatar (initials from name) |
| Date/month pickers | AppDatePicker / AppTimePicker |
| Empty/error/loading | AppEmptyState, AppErrorState, AppSkeleton |
| Confirmations | AppDialog (batch overwrite confirm) |
| Selection bars, sheets | AppBottomSheet (S3), AppMenu (row context) |
| Status legend & stats | AppBadge, AppStatTile, AppCharts (S5/S6 bars) |
| KPI entry | AppBanner (pipeline warning, conflict) |
08 — Form Specifications (Attendance Module)
- 1. Mark form (single) —
POST /attendance - 2. Batch form —
POST /attendance/bulk - 3. Update form (correction) —
PATCH /attendance/:id - 4. Report trigger —
POST /reports/generate - 5. Biometric ingest (machine→API, not a user form)
- 6. Validation error UX mapping (server → fields)
Exact field-level specs for the three write surfaces (mark, batch, update) plus the report trigger. Every field maps to a DTO — no client-invented fields. Validation: server
class-validatorrules as written in the DTOs; client mirrors them for pre-submit feedback (server stays authoritative, 00-shared/06 §5).
1. Mark form (single) — POST /attendance
DTO: MarkAttendanceDto (mark-attendance.dto.ts:11-49).
| Field | Type | Required | Server rule | Client control |
|---|---|---|---|---|
studentId | MongoId string | ✅ | @IsMongoId() (mark-attendance.dto.ts:13-14) | hidden (from selected row) |
classId | MongoId string | ✅ | @IsMongoId() (mark-attendance.dto.ts:16-17) | hidden (from class context) |
date | YYYY-MM-DD string | ✅ | @IsDateString() (mark-attendance.dto.ts:20-22) | AppDatePicker; serialized yyyy-MM-dd in tenant-local date (see 14_QA_Checklist.md §timezone) |
status | enum | ✅ | @IsEnum(AttendanceStatus) — one of present,absent,late,half_day,leave,holiday (mark-attendance.dto.ts:24-28; enum attendance.schema.ts:7-14) | StatusChip/popover; invalid → client-blocked; server 400 VALIDATION_ERROR w/ details (e2e p1-operations.e2e-spec.ts:222-228) |
checkIn | ISO date-time string | optional | @IsOptional() + @IsDateString() (mark-attendance.dto.ts:30-33) | AppTimePicker default now; sent as full ISO (new Date(...), attendance.service.ts:32) |
checkOut | ISO date-time string | optional | @IsOptional() + @IsDateString() (mark-attendance.dto.ts:35-38) | AppTimePicker |
source | string | optional | @IsOptional() @IsString() (mark-attendance.dto.ts:40-43) — client never sends (server default manual, attendance.schema.ts:46-51); invalid value → schema enum rejection risk (OQ) | hidden |
remarks | string | optional | @IsOptional() @IsString() (mark-attendance.dto.ts:45-48) | AppTextField in popover, ≤ 500 chars |
Success: 201 → data = full attendance doc (_id, studentId, classId, date,
status, source, checkIn/checkOut?, remarks?, markedBy?, version, timestamps —
attendance.schema.ts:24-55 + base.schema.ts).
Errors: 400 details per field; 404 if referenced entities invalid (upstream modules);
409 DUPLICATE_RESOURCE only if upsert races a concurrent create on the unique index
(attendance.schema.ts:59-62) — client: refresh grid; 429/5xx per shared mapping.
Idempotency: re-submit is safe by design (upsert findOrCreate, attendance.repository.ts:17-37);
still send Idempotency-Key on offline flush to dedupe events.
2. Batch form — POST /attendance/bulk
DTO: BulkMarkAttendanceDto { records: MarkAttendanceDto[] } (mark-attendance.dto.ts:51-54).
- Client build: selection rows →
recordsarray, each a full mark payload (studentId/classId/date/status; optional checkIn/checkOut/remarks only if set). - Validation (client-side, mirrors server per-record): every record must pass §1 rules;
pre-validate all records before submit — the server loops sequentially
(
attendance.service.ts:48-54) and a single invalid record throws, leaving earlier records saved (partial success — OQ-2). Client blocks submit if any record invalid. - Size: no server cap in DTO; practical limit = one class day (≤ ~150 records). Offline flush batches ≤ 100 (see 15).
- Success: 200/201 →
data= array of docs (same order as records). Client reconciles grid per doc bystudentId. - Failures: map per-record where possible; snackbar "9 of 34 failed" + retry of failed subset (safe — upsert).
3. Update form (correction) — PATCH /attendance/:id
DTO: UpdateAttendanceDto (update-attendance.dto.ts:4-22). Partial replace: only sent
fields are $set (base.repository.ts:57-66).
| Field | Type | Server rule | Notes |
|---|---|---|---|
status | string | @IsString() only — no enum validation (update-attendance.dto.ts:8) | OQ-6: server may persist invalid value via $set (no runValidators). Client MUST restrict to the 6-status enum; keep server-mirror validation on |
checkIn | Date | @IsOptional() (raw Date, no validator) (update-attendance.dto.ts:12) | ISO string → Date |
checkOut | Date | @IsOptional() (update-attendance.dto.ts:16) | as above |
remarks | string | @IsOptional() @IsString() (update-attendance.dto.ts:21) | clearing = send "" |
- Semantics: doc must exist → 404 "Attendance record not found." if not
(
attendance.service.ts:75-79,85-87). Success →data= updated doc (version +1,base.repository.ts:63). - Event:
AttendanceUpdated {attendanceId, studentId, changes:[keys]}—changes=Object.keys(dto)(attendance.service.ts:94-98). - UI: from S7 day detail / S4 read grid (teacher,
attendance.mark+attendance.edit). Prefill current values from the fetched doc. Undo via re-PATCH (not supported server-side as an action — client re-sends previous values).
4. Report trigger — POST /reports/generate
DTO: GenerateReportDto (reports module; type from ReportType, report-job.schema.ts:8-9).
For attendance: {type: 'attendance_summary', classId?, startDate?, endDate?} →
{jobId, status:'queued'} (reports.service.ts:28-44). Poll GET /reports/:jobId.
5. Biometric ingest (machine→API, not a user form)
POST /biometric/ingest {studentId (MongoId), deviceId (string), timestamp (date-string), mode? (string)} (create-biometric-log.dto.ts:4-21). Displayed read-only in S8; no client
form (device pushes). deviceId is a free string today — no device-registry validation
(device biometric-device.schema.ts:15-31 exists; association OQ-3).
6. Validation error UX mapping (server → fields)
Server 400 details array {field, message} (00-shared/07 §3). Client maps:
| Server field | Client target |
|---|---|
studentId/classId | hidden fields — treat as 422-class failure (snackbar, refresh roster) |
date | date picker error text "Enter a valid date" |
status | chip popover error; re-open with current value |
checkIn/checkOut | time field error "Enter a valid time" |
remarks | remarks field error |
Envelope-level errors (409/422/429/5xx) render per 00-shared/06 §5 mapping, never raw server internals (00-shared/07 §11).
09 — User Behaviour (Attendance Module)
- 1. Fast-marking patterns
- 2. Mistakes & recovery
- 3. Conflict semantics (must-read)
- 4. Notification-driven behaviour (parents)
- 5. Data-entry ergonomics (physical)
- 6. Behaviour rules summary
How teachers actually use the marking surface, the mistakes they make, and what the UI must do about each. All corrective behaviour maps to backend semantics — most importantly the upsert-overwrite model (
attendance.repository.ts:23-29) and the one-doc-per-student-per-day invariant (attendance.schema.ts:59-62).
1. Fast-marking patterns
| Pattern | Frequency driver | UI support |
|---|---|---|
| Mark-by-exception (assume present) | 90%+ of classes | Default-present chips (dashed = unsaved); only changed rows send requests |
| Sweep all absent | surprise absences (sports day, late bus) | FAB "Mark all absent" → bulk confirm → UndoBar |
| Tap-cycle to correct | walk-by marking | 1/2/3-tap cycle Present→Absent→Late→Present (06 §2) |
| Filter to "unmarked" | end-of-class sweep | StatusFilterChips with counts |
| Mark-late for stragglers | late arrivals | popover: late + checkIn=now (06 §4) |
| Batch for a subgroup | lab sessions, early dismissals | long-press multi-select → BatchEditSheet |
The 60-second rule: the design goal — an entire 40-student class marked in ≤ 60 s. Anything requiring a dialog per student violates it; the only blocking dialog is the overwrite-conflict banner (deliberate, because re-marks silently change data).
2. Mistakes & recovery
2.1 Wrong date
- What happens: teacher marks today but means yesterday; or opens yesterday to correct and accidentally marks today.
- System behaviour: each day is independent (docs keyed by
date). Nothing merges; wrong-day marks persist as separate docs. - UI:
- Date is pinned in the AppBar subtitle and the grid header ("Mon, 20 Jul 2026") —
- mark requests embed the pinned date; the chip shows the date in the popover title.
- A "Marked for Mon 20 Jul" confirmation snackbar on first mark of the session (once per date-switch, not per mark).
- Correcting: teacher switches date → marks correct → the wrong-day record is found in
S4 day view → PATCH status to
present/holidayas appropriate (no delete endpoint exists — OQ-11: noDELETE /attendance/:idinattendance.controller.ts; a "wrong day" fix = setholidayor remark, flagged for the product owner).
2.2 Wrong status (mistap)
- What happens: tap-cycling overshoots (present→absent→late when only absent was meant), or taps the wrong row.
- System behaviour: single-tap writes immediately (upsert). No server-side undo.
- UI:
- The 3-state cycle makes overshoot recoverable in one more tap (back to intended).
UndoBar(4 s) after every mark: reverts by re-sending the previous status (safe — upsert overwrite).- Haptics
lightImpacton save confirm the tap landed on the right row.
2.3 Wrong student (row confusion)
- What happens: two similar names / misaligned rows on a long grid.
- UI: rows alternate tint (zebra,
surfaceVariant4% alpha) only when > 30 students; name istitleMedium+rollNumber(mono,student.schema.ts:23-25); popover title always shows full name + roll no; filters collapse the list so targets are adjacent.
2.4 Marking the same student in two classes (same day)
- What happens: student attends class A period 1 and class B period 2; teacher B marks
them; unique index
{tenantId, studentId, date}(attendance.schema.ts:59-62) permits one doc per day — remarking from class B overwrites class A's record and keeps class A'sclassId(attendance.repository.ts:23-33). - UI: first grid session of the day shows a one-time inline hint on rows already marked today: chip shows existing status + tooltip "Already marked in Class A — this will overwrite". The overwrite-conflict banner (§3) covers the multi-select case.
2.5 Typos in remarks / check-in times
- What happens: 09:15 vs 09:50; wrong free-text note.
- UI: popover shows existing values ("currently 09:12"); PATCH flow (S7/S4) edits in place. Client-side numeric/time formatting; remarks free text ≤ 500 chars.
3. Conflict semantics (must-read)
| Situation | Server truth | UI |
|---|---|---|
| Remark same status | no-op upsert (same values re-saved, attendance.repository.ts:25-27) | chip flashes, no banner |
| Remark different status, single row | overwrite | chip flips + "was: absent" transient label |
| Batch containing already-marked rows | overwrite all in one bulk | amber banner "N already marked — will be overwritten"; CTA "Overwrite N" (06 §3) |
| Offline queue collides with newer online data on flush | last-write-wins per student/date (upsert) | flush preview lists collisions; user confirms or drops stale rows |
| Two devices/teachers race | unique index: one doc; both upserts, last write wins | rare; grid refresh on 409 |
4. Notification-driven behaviour (parents)
- Alert arrival (when
(planned)dispatch lands): parent taps →studylyon://attendance/:date→ S7 day view → sees status + source (manual/biometric,attendance.schema.ts:16-21). - Copy honesty: "3 absences this month" (worker counts all absent docs, no window —
attendance.worker.ts:70-74; OQ-1). Never "consecutive".
5. Data-entry ergonomics (physical)
- One-hand tablet landscape: chips right side, thumb zone.
- Landscape keyboard on desktop: arrows navigate rows,
P/A/L/H/V/Dset status on focused row,Enteropens popover,Escexits selection/modes. - Sticky header (MarkedCountBar + filters) so "done?" is always visible mid-scroll.
- Auto-scroll to next unmarked row after filter-to-unmarked marking (proposed).
6. Behaviour rules summary
- Never block single-row marking with a dialog (exception: overwrite-conflict in batch).
- Every write is instantly visible in chip + MarkedCountBar (optimistic + pending indicator, 13_State_Management.md §5).
- Every failure is recoverable: snackbar retry; offline queue flush; undo where safe.
- Time/date shown in tenant-local time; wire values always
YYYY-MM-DD/ ISO-8601. - Honest about pipeline gaps (biometric "captured, not applied", alert "planned").
10 — Interaction Specification (Attendance Module)
- 1. Grid gesture map (S2)
- 2. Batch & sheet gestures
- 3. Keyboard & desktop (S2/S4/S7)
- 4. Motion language (module-specific)
- 5. Haptics
- 6. Micro-interactions
- 7. Anti-patterns (explicitly forbidden)
Gesture, keyboard, haptic, and motion contract for the attendance surfaces. Bases: 00-shared/08_Interaction_&_Motion.md (global) + 00-shared/02 §6 (motion tokens). Conflicts resolve in favor of this file for attendance screens.
1. Grid gesture map (S2)
| Gesture | Target | Action |
|---|---|---|
| Tap | StatusChip | Cycle status: Present → Absent → Late → Present (06 §2); disabled while in-flight |
| Tap | row (not chip) | Open StatusChipPopover (full options + times + remarks) |
| Long-press | row | Enter multi-select; row becomes checkbox row; batch bar appears (selectionClick haptic) |
| Long-press + drag | rows | Range-select (contiguous) — phone ergonomic equivalent of shift-click |
| Swipe left | row | Quick-mark absent (with row-confirm haptic; disabled in selection mode) — mirrors global swipe pattern (00-shared/08 §1); keep ≤ 2 swipe actions |
| Swipe right | row | Quick-mark present (same guard) |
| Tap | MarkedCountBar status chip | Filter grid to that status (toggle; "All" resets) |
| Pull down | grid | Refresh: re-fetch class attendance + roster (bypasses cache, 00-shared/06 §3.3) |
| Back gesture | grid with selection | Exit selection first (never pop screen while selecting) |
Gesture arbitration rules: chip tap vs row tap — chip has priority on its own hit area (48 dp); long-press on chip = popover (not selection); long-press on row body = selection. No double-tap anywhere on the grid (reserved globally for media, 00-shared/08 §1).
2. Batch & sheet gestures
- Sheet (S3) drag-handle to dismiss = cancel; back/Esc = cancel; barrier tap = cancel (batch is not destructive by itself).
- "Apply" while a status chip in sheet is highlighted = immediate submit (no second tap).
UndoBaraction = one tap, 4 s window,m-baseslide-in under batch bar.
3. Keyboard & desktop (S2/S4/S7)
| Key | Action |
|---|---|
P | set focused row → present |
A | set focused row → absent |
L | set focused row → late (popover for checkIn) |
H / V / D | half_day / leave / holiday |
Enter | open popover for focused row |
Space | toggle selection of focused row |
Shift+click | range-select in multi-select mode |
Esc | exit selection → close popover → close sheet (priority order) |
Ctrl+K | global search (unchanged, 00-shared/08 §2) |
←/→ | month navigation in S4 |
Tab | grid table traversal (row-wise: chip → popover → next row) |
Right-click (desktop) on chip = popover (mirror of long-press, 00-shared/08 §2).
Hover: Mo.m-fast surface tint + tooltip on chips after 500 ms.
4. Motion language (module-specific)
| Interaction | Token |
|---|---|
| Chip status flip | Mo.m-fast (150 ms, easeOutCubic) color morph + AnimatedSwitcher icon |
| Chip save confirm (settle) | brief Mo.m-instant scale pulse (120%) on chip |
| Batch bar / selection entry | Mo.m-base slide-up, 250 ms easeInOutCubic |
| Sheet (S3/popover) | platform sheet default (Mo.m-base) |
| MarkedCountBar count change | AnimatedSwitcher Mo.m-fast; no layout shift (00-shared/08 §4) |
| Heatmap day cell fill (after a mark lands) | Mo.m-fast color fill from neutral → status |
| UndoBar | Mo.m-base slide-in; auto-dismiss fade Mo.m-fast |
| Grid row entrance (first load) | Mo.m-entrance stagger 40 ms (skipped on refetch; off in reduced-motion) |
Reduced-motion: everything Mo.m-instant fades, no stagger, no pulse (00-shared/08 §4).
5. Haptics
| Event | Haptic |
|---|---|
| Chip status change applied | lightImpact() |
| Batch applied (N rows) | lightImpact() |
| Mark failed / error snackbar | mediumImpact() |
| Long-press selection entry | selectionClick() |
| Overwrite-conflict banner | mediumImpact() (once per batch, not per row) |
| Sweep-all-absent confirm | heavyImpact() only when overwriting marked rows |
Rules: haptics off with system setting; none on web/desktop (00-shared/08 §3).
6. Micro-interactions
- Chip "was: absent" label — transient 2 s text under chip after overwrite; fades
Mo.m-fast; announced once (live region). - MarkedCountBar pulse — one-time
Mo.m-fastscale pop when the day hits "all marked" (100% state) — celebratory but subtle; skipped in reduced-motion. - Unmarked chip (dashed) — pressing it = first mark; chip "solidifies"
(
Mo.m-fast) once saved. - 409 collision — snackbar + grid refresh; if the row was being edited in the popover, reopen with server values (never discard user's pending input silently).
7. Anti-patterns (explicitly forbidden)
- No double-submit: submit buttons disabled while pending (00-shared/08 §6).
- No full-screen flashes or color-only status changes (00-shared/09 §9).
- No haptic spam on batch (one per batch, not per row).
- No swipe actions in selection mode (ambiguous with range-select).
- No parallax/stagger on reduced-motion devices.
11 — Design System Mapping (Attendance Module)
- 1. Status color semantics (single source of truth)
- 2. Module components → M3/Flutter mapping
- 3. Typography mapping
- 4. Layout tokens
- 5. Motion tokens
- 6. Iconography
- 7. Theming rules (attendance-specific)
- 8. Accessibility specifics
Extends 00-shared/04_Design_System_Mapping.md with attendance-specific mappings: status semantics, module components → M3 widgets/tokens, typography, layout, and theming rules. Tokens per 00-shared/02.
1. Status color semantics (single source of truth)
| Status | M3 role | Light token | Dark token | Icon (filled) |
|---|---|---|---|---|
present | success | #16A34A / successContainer | dark variant (§1.4) | check_circle |
absent | error | #BA1A1A / errorContainer | dark variant (§1.4) | block |
late | warning | #F97316 / warningContainer | dark variant (§1.4) | schedule |
half_day | warning | as late (distinct icon) | same | right_half_circle |
leave | neutral | surfaceVariant fg onSurfaceVariant | dark variant | beach_access |
holiday | neutral | surfaceVariant | dark variant | wb_sunny |
unmarked | outline | outline dashed chip | outlineVariant | radio_button_unchecked |
Rules: icon + label always (never color-only, 00-shared/09 §9:76); chip text contrast
≥ 4.5:1 light, ≥ 4.5:1 dark (use on* tokens); error container text #601410 dark.
2. Module components → M3/Flutter mapping
| Component (07) | M3 pattern | Flutter widgets | Tokens |
|---|---|---|---|
AttendanceGrid | Table-like list | ListView.builder + itemExtent, sticky header via SliverPersistentHeader | row 56 dp, surface bg, zebra surfaceVariant 4% |
StatusChip | Filter/Input chip | InkWell + Container (rounded r-full/r-md), AnimatedContainer | *Container role colors, labelLarge |
StatusChipPopover | Bottom sheet | showModalBottomSheet + Wrap of chips | surfaceContainerHigh, e-5, r-xl top |
BatchEditSheet | Bottom sheet | showModalBottomSheet, AppButton | as popover; AppBanner for conflict |
MarkedCountBar | Sticky summary strip | custom Material strip + Wrap chips | surfaceContainerLow, e-2, labelMedium |
StatusFilterChips | Filter chips | FilterChip with Badge counts | selected secondaryContainer, else surfaceVariant |
CalendarHeatmap | Calendar | GridView.builder (7 cols), InkWell cells | cells surfaceVariant/status tints, labelMedium |
DaySummaryCard | Card | Card.filled + AppInfoRows | surfaceContainerLow, e-1, r-lg |
SummaryDonut | Chart | AppCharts (fl_chart wrapper) | 02 chart tokens; Semantics summary |
AbsenceAlertCard | Banner | AppBanner warning | tertiaryContainer |
BiometricDeviceTile | List tile | AppListTile + AppBadge | row 72 dp (device meta) |
UndoBar | Snackbar variant | custom bottom bar / SnackBar with action | inverseSurface |
ReportJobCard | Card + progress | AppCard + LinearProgressIndicator | e-1, primary progress |
3. Typography mapping
| Use | Token | Widget |
|---|---|---|
| Screen title (grid) | headlineMedium | AppBar title |
| Grid subtitle (date + count) | bodyMedium onSurfaceVariant | under title |
| Student name | titleMedium | row title |
| Roll number / admission no | mono (tabular) | row subtitle bodySmall |
| Status chip label | labelLarge | chip text |
| MarkedCountBar chips | labelMedium | counts |
| Heatmap day number | labelMedium | cell |
| Form labels | bodyMedium | popover fields |
4. Layout tokens
- Row height 56 dp; dense a11y mode 44 dp chips (allowed, 02 §9); sheet half-height
(S3) ~ 40% screen; heatmap cell 40×40 min; batch bar 64 dp; sticky elements
e-2(bar) /e-3(FAB). - Grid horizontal padding
Sp 16phone /Sp 24tablet+.
5. Motion tokens
Mo.m-fast status flips; Mo.m-base sheets/bars; Mo.m-instant settle pulse;
Mo.m-entrance first-load stagger (reduced-motion → none). All from 02 §6.
6. Iconography
Material Symbols outlined for chrome; filled for status icons (per component table).
Sizes: 16 dense chips, 20 sheet items, 24 default, 32 empty state, 40 device header.
Every icon paired with a label/tooltip + Semantics (02 §7).
7. Theming rules (attendance-specific)
- Status colors come only from the mapping in §1 — never bespoke hexes in widgets.
- Zebra striping and "was:" transient labels must exist in dark mode too (contrast audit per 00-shared/09 §2).
- Tenant branding (
organization.branding.color) overridesprimaryonly — status colors must remain semantic and readable under any seed (02 §1:39-40). - No hardcoded durations/padding in grid widgets (00-shared/04 §7).
8. Accessibility specifics
- Chips announce state changes (live region),
Semantics(toggled:); heatmap cells announce "20 Jul — 28 present, 2 absent". - Focus rings
primary2 dp offset 2 on all interactive cells (desktop). - 200% text scale: chips collapse to icon + short label; grid stays tabular (00-shared/09 §4).
12 — API Mapping (Attendance Module)
- 0. Module-wide request envelope & client policy
- 1. Mark one student — S2 (single row)
- 2. Batch mark — S2/S3
- 3. Class-by-date roster — S2/S4
- 4. Student history — S7
- 5. Class summary — S5 (live tile)
- 6. Single record — S2 popover prefill / S7 edit
- 7. Update / correct — S4/S7
- 8. Biometric ingest — S8 (machine→API)
- 9. Async attendance report — S5/S6
- 10. Dashboard attendance KPI — home
- 11. Scheduler & events (context, not client endpoints)
- 12. Client error mapping (module)
Exact wire contract for every screen → endpoint. Base
/api/v1; envelope per 00-shared/07. All endpoints fromattendance.controller.ts,biometric.controller.ts,reports.controller.ts,dashboard.controller.ts; business rules from the services. Global guard chain:RateLimitGuard → JwtAuthGuard → RbacGuard(app.module.ts:129-131); attendance controller adds no per-endpoint@Permissions(OQ-5).
0. Module-wide request envelope & client policy
| Aspect | Contract |
|---|---|
| Base | https://api.<domain>/api/v1 |
| Headers | Authorization: Bearer <accessToken>; x-request-id; Content-Type: application/json; Idempotency-Key (UUID) on offline writes |
| Success | {success,message,data,meta?,timestamp,requestId} |
| Error | {success,message,error:{code,details?},timestamp,requestId} |
| Tenancy | tenantId from JWT claim — never in body (00-shared/07 §6) |
| Caching | attendance lists: last-good cache, SWR, TTL 5 min (00-shared/06 §3.3) |
| Offline | reads cached; writes queued (this module defines an offline queue — 00-shared/06 §3.7) |
| Retry | backoff on 5xx/network; no auto-retry on 429 |
Pagination note (OQ-4): none of the attendance GETs paginate — data is a bare
array, no meta. Client strategy: treat as full lists; history views page client-side.
1. Mark one student — S2 (single row)
| Endpoint | POST /attendance (attendance.controller.ts:25-27) |
| Request | MarkAttendanceDto: {studentId, classId, date('YYYY-MM-DD'), status, checkIn?, checkOut?, source?, remarks?} (mark-attendance.dto.ts:11-49) |
| Success | 201 → data = attendance doc (_id, studentId,classId,date,status,source,checkIn?,checkOut?,remarks?,markedBy?,version,createdAt,updatedAt) |
| Business | idempotent upsert by (tenantId, studentId, date) (attendance.repository.ts:17-37); emits AttendanceMarked {studentId,status,date} (attendance.service.ts:37-44); unique index attendance.schema.ts:59-62 |
| Errors | 400 VALIDATION_ERROR (invalid status/date/ids — e2e p1-operations.e2e-spec.ts:222-228); 404 upstream refs; 409 race; 429; 5xx |
| Source | attendance.service.ts:24-46 |
| Offline | queue locally; flush with Idempotency-Key |
2. Batch mark — S2/S3
| Endpoint | POST /attendance/bulk (attendance.controller.ts:28-30) |
| Request | BulkMarkAttendanceDto {records: MarkAttendanceDto[]} (mark-attendance.dto.ts:51-54) |
| Success | data = array of docs, order preserved; one AttendanceMarked per record (attendance.service.ts:48-54) |
| Errors | 400 if any record invalid (sequential loop throws on first bad record — earlier records may already be saved, OQ-2); 409/429/5xx |
| Source | attendance.service.ts:48-54; END_TO_END_USER_FLOWS.md:269-270,288 |
| Offline | flush in chunks ≤ 100 records |
3. Class-by-date roster — S2/S4
| Endpoint | GET /attendance/class/:classId?date=YYYY-MM-DD (attendance.controller.ts:31-36) |
| Success | data = array of docs for that class+date (empty if none) |
| Errors | 400 missing date; 404 cross-tenant class; 429 |
| Source | attendance.service.ts:56-61; attendance.repository.ts:39-44; e2e p1-operations.e2e-spec.ts:211-220 |
| Note | used per day; S4 month = 1 call/day (cache; OQ-4) |
4. Student history — S7
| Endpoint | GET /attendance/student/:studentId?startDate&endDate (both optional) (attendance.controller.ts:37-43) |
| Success | data = array of docs, date-range filtered (unfiltered = full history) |
| Errors | 404 cross-tenant; 429 |
| Source | attendance.service.ts:63-73; attendance.repository.ts:46-58; END_TO_END_USER_FLOWS.md:324-325,348,378-379,401 |
| Note | parent/student gating open (OQ-7) |
5. Class summary — S5 (live tile)
| Endpoint | GET /attendance/summary?classId&startDate&endDate (all required) (attendance.controller.ts:44-50) |
| Success | data = {total, summary:{status:count}} (attendance.service.ts:103-113) |
| Errors | 400 missing params; 429 |
| Source | attendance.service.ts:103-113 |
| Note | in-memory aggregation (OQ-8) |
6. Single record — S2 popover prefill / S7 edit
| Endpoint | GET /attendance/:id (attendance.controller.ts:51-53) |
| Success | data = doc |
| Errors | 404 RESOURCE_NOT_FOUND "Attendance record not found." (attendance.service.ts:77) |
| Source | attendance.service.ts:75-79 |
7. Update / correct — S4/S7
| Endpoint | PATCH /attendance/:id (attendance.controller.ts:54-59) |
| Request | UpdateAttendanceDto {status?, checkIn?, checkOut?, remarks?} (update-attendance.dto.ts:4-22) |
| Success | data = updated doc (version +1, base.repository.ts:57-66); emits AttendanceUpdated {attendanceId, studentId, changes:[keys]} (attendance.service.ts:88-99) |
| Errors | 404 missing; 400; OQ-6 (no enum validation on status — client restricts); 429 |
| Source | attendance.service.ts:81-101 |
| Note | changes keys → UI "what changed" toast; audit trail via event map (event-queue-map.ts:18-21) |
8. Biometric ingest — S8 (machine→API)
| Endpoint | POST /biometric/ingest (biometric.controller.ts:14-18) |
| Request | CreateBiometricLogDto {studentId, deviceId, timestamp, mode?} (create-biometric-log.dto.ts:4-21) |
| Success | data = raw log doc (biometric-log.schema.ts:7-27) — stored only, not applied to attendance (OQ-3) |
| Source | biometric.service.ts:14-16 |
| Note | biometric-sync cron */15 (scheduler.service.ts:71-76) has no worker today; deviceId unvalidated against biometric_devices (biometric-device.schema.ts:15-31) |
9. Async attendance report — S5/S6
| Endpoint | POST /reports/generate (reports.controller.ts:14-18) |
| Request | {type:'attendance_summary', classId?, startDate?, endDate?} (report-job.schema.ts:8-9) |
| Success | data = {jobId, status:'queued'} (reports.service.ts:28-44) |
| Poll | GET /reports/:jobId → `data = {type, status(queued |
| Errors | 404 unknown job; 400 bad type; 429 |
| Source | reports.service.ts:110-137; report.worker.ts:7 |
| Note | teacher role lacks report.* (role.schema.ts:31) — admin-only today (OQ) |
10. Dashboard attendance KPI — home
| Endpoint | GET /dashboard/overview (dashboard.service.ts:18-71) |
| KPI | attendance: {total, summary, rate} — rate = round(present/total × 100) (dashboard.service.ts:57-64) |
| Tap | deep link to S2 for today's class |
11. Scheduler & events (context, not client endpoints)
| Item | Source |
|---|---|
AttendanceMarked / AttendanceUpdated → queue attendance-process, job process-attendance | event-queue-map.ts:14-21 |
attendance-process worker: absence watch (all absent docs ≥ 3 → log), bulk-import branch | attendance.worker.ts:16,45-54,57-102 |
Repeatable attendance-report-daily 07:00 UTC → report-generate | scheduler.service.ts:99-104; attendance-report.job.ts:13-24 |
Repeatable biometric-sync */15 → biometric-sync queue (no worker) | scheduler.service.ts:71-76; queue.constants.ts:7 |
| Permissions available | attendance.mark, attendance.edit (permissions.constants.ts:29-30); report.generate, report.read (39-40); biometric.log.create/read, biometric.device.manage (41-43) |
12. Client error mapping (module)
| Code | Attendance-specific UX |
|---|---|
400 VALIDATION_ERROR | field errors (§08_Form_Specifications.md §6); bulk → pre-validate client-side |
401 UNAUTHENTICATED | refresh once; else session expiry flow (00-shared/06 §5) |
403 PERMISSION_DENIED | hide grid actions; batch bar disabled |
404 RESOURCE_NOT_FOUND | refresh roster; "record no longer exists" state |
409 DUPLICATE_RESOURCE | grid refresh (upsert race); offline flush collision preview |
422 BUSINESS_RULE_VIOLATION | context banner (not currently emitted by attendance service — future use) |
429 RATE_LIMITED | backoff, no auto-retry, countdown |
| 5xx | generic + requestId; retry offered |
13 — State Management (Attendance Module)
- 1. Cubits & responsibilities
- 2. RosterCubit (S1 → S2)
- 3. MarkingCubit (S2) — the heart
- 4. Offline marking queue (module-defined;
00-shared/06 §3.7) - 5. HistoryCubit (S4/S7)
- 6. ReportCubit (S5/S6)
- 7. DeviceCubit (S8)
- 8. Cross-cutting
Per-screen state on top of 00-shared/06_State_Management.md (Bloc/Cubit, repository layer, SWR cache, WS, optimistic-update rules, offline queue contract). The module's defining problem: a marking grid with an offline write queue plus fast roster reads and async report polling.
1. Cubits & responsibilities
| Cubit | Screen(s) | State |
|---|---|---|
RosterCubit | S1, S2 | classes → roster rows + existing marks + live mark status |
MarkingCubit | S2, S3 | per-row status transitions, pending/queued flags, selection, batch apply, undo stack |
HistoryCubit | S4, S7 | month heatmap (per-day fetch fan-out), day summary, student history |
ReportCubit | S5, S6 | summary tile + async report job lifecycle (poll) |
DeviceCubit | S8 | devices, last punch, ingest counts, pipeline state |
All implement LoadState {Initial, Loading, Success, Error} (00-shared/06 §3.1);
lists implement PaginatedListMixin only where the API paginates — attendance reads
do not (OQ-4), so history uses client-side paging.
2. RosterCubit (S1 → S2)
state: {loadState, classes, classId?, date, rows: [AttendanceRow], existingByStudentId: Map}
events: PickClass, ChangeDate, Refresh, Retry
AttendanceRow = {student, doc?}— doc fromGET /attendance/class/:classId?date=(server state); absent doc = unmarked (default-present prospective state).- Roster source: students by
classId(student.schema.ts:38-39); class picker from academics (class.schema.ts:8-35). Roster fetch:GET /students?classId=(students module) — flagged as module dependency; fallback: students endpoint supportsq/pagination (00-shared/07 §5). - Cache: roster rows 24 h TTL (reference data,
00-shared/06 §3.3); existing marks 5 min TTL ("volatile", attendance today). Pull-to-refresh bypasses cache. - Realtime: subscribe to
attendance.changedWS topic (00-shared/06 §3.4) for the currentclassId+date→ re-fetch marks (another teacher/device wrote) — keeps grid honest; on 409 collision, same path.
3. MarkingCubit (S2) — the heart
state: {
rows, // statuses keyed by studentId
pending: Set<studentId>, // in-flight to server
queued: Map<studentId, MarkOp>,// offline queue
selection: Set<studentId>, // multi-select
undoStack: [{studentId, prevStatus}],
lastOp: {opId, count, failed: Set<studentId>}
}
events: CycleStatus(row), OpenPopover(row), SelectMany, ApplyBatch(status),
UndoLast, FlushQueue, RetryFailed, DiscardRow
Optimistic vs sync policy (per 00-shared/06 §3.5):
| Write | Policy | Reason |
|---|---|---|
| Single mark (POST upsert) | optimistic (flip chip → rollback on error) | upsert is idempotent & non-destructive; fast classroom cadence demands zero latency |
| Batch apply (POST bulk) | optimistic with conflict banner | overwrite semantics need the pre-flight banner (06 §3); after confirm, flip all, rollback failures |
| PATCH correction (S4/S7) | pessimistic (spinner, no local flip) | correction has event side effects (AttendanceUpdated) — "mutations with side effects are never optimistic" (00-shared/06 §3.5) |
| Sweep-all-absent | optimistic + UndoBar | reversible via undo re-send |
Rollback: on 4xx/5xx revert row to previous status, failed set, snackbar w/ retry.
Undo stack: max depth 50 ops; UndoBar for 4 s; undo = re-send previous statuses
(bulk where ≥ 2).
4. Offline marking queue (module-defined; 00-shared/06 §3.7)
MarkOp = {opId: uuid, kind: 'mark'|'batch', payload: MarkPayload[], createdAt, tenantId}
- Capture: offline or write-error → op appended to persisted queue (Hive box
att.queue, key{tenant}:{classId}:{date}), rows flaggedqueued(dashed chip + cloud icon). - Dedupe: before enqueue, drop queued ops for the same
studentId+date(replace with newer payload — last-write-wins mirrors server upsert). - Flush: on reconnect (
ConnectivityCubit) → sequentialPOST /attendance/POST /attendance/bulkper op, withIdempotency-Key= opId. Success → remove op; failure → keep, retry with backoff, cap 5 attempts then surface "N marks waiting — review". - Collision on flush (409): fetch server doc for that student+date; if differs from
queued payload → mark row "conflicted", show preview sheet; user confirms overwrite or
discards (
09 §3). - Batch chunking: flush ≤ 100 records per bulk call (server has no cap; sanity bound).
- Ordering: FIFO within a class/date; cross-class FIFO by createdAt.
5. HistoryCubit (S4/S7)
- Month view: for each day in month →
GET /attendance/class/:classId?date=(S4) or one range callGET /attendance/student/:studentId?startDate&endDate(S7 — prefer the range call: S7 = 1 request; S4 has no range endpoint, so fan-out 30 calls with a 5-min day-cacheatt.days:{classId}:{date}). - Day cache invalidated when a mark lands for that date (local write) or
attendance.changedWS event arrives. - Client-side paging for S7 long ranges: slice the returned array by month (OQ-4).
6. ReportCubit (S5/S6)
- Live tile:
GET /attendance/summary(SWR, 5 min). - Job:
POST /reports/generate→ pollGET /reports/:jobIdevery 2 s whilequeued|processing(cap 5 min), thencompleted|failedterminal states (report-job.schema.ts:13-18); background poll survives screen switch (same cubit in shell scope), snackbar "Report ready" on completion. - Never optimistic for job submission (side effects).
7. DeviceCubit (S8)
- Devices + last punch + ingest counts (sources:
biometric_logsbydeviceId+timestamp,biometric-log.schema.ts:26; device list endpoint(planned), OQ-3). Poll 60 s while screen visible (no WS topic today).
8. Cross-cutting
- Auth/session: all cubits react to
sessionExpired→ preserve unsaved queue, redirect login, restore on re-login (00-shared/06 §3.6). - Permissions:
attendance.markgates chip taps & FAB;attendance.editgates PATCH entry points;report.readgates S5/S6;biometric.*gates S8 — evaluated at route build + guard (00-shared/05 §9); server authoritative (OQ-5). - Testing hooks: pure-Dart cubits with mocked repositories; widget tests for the
three-state machine + optimistic rollback + queue flush (
00-shared/06 §6).
14 — QA Checklist (Attendance Module)
- 1. Data correctness — uniqueness & upsert
- 2. Edit-after-notification / correction flows
- 3. Timezone & date handling
- 4. Large-class & performance
- 5. Offline marking
- 6. Idempotent retries & partial failures
- 7. Permissions & roles
- 8. Biometric integration
- 9. Accessibility (module additions to 00-shared/09 §12)
- 10. Localization & formatting
- 11. Performance budgets (module)
- 12. Release gates (module additions)
Module-specific checklist layered on 00-shared/10_QA_Baseline.md + 00-shared/09 accessibility baseline. Every item is testable; backend-rule items cite the source line.
1. Data correctness — uniqueness & upsert
-
Marking the same student+date twice → one doc, second write overwrites
(
attendance.repository.ts:23-29); grid shows single chip state, no duplicates. -
Unique index
{tenantId, studentId, date}(attendance.schema.ts:59-62) enforced: two concurrent POSTs for same student+date → one doc, other gets 409 or converges. -
Same student marked from a second class on the same date → original
classIdis retained (attendance.repository.ts:29-33); grid shows "already marked in Class A" tooltip. -
Batch: N records → N docs, order preserved; one
AttendanceMarkedevent per record (attendance.service.ts:48-54). -
PATCH sends only changed fields: unchanged fields untouched (
$set,base.repository.ts:57-66);versionincrements on every update. -
PATCH
statuswith valid enum values only (OQ-6: server does not validate — client enforces; test that UI cannot submit invalid). -
Cross-tenant: reading another tenant's record id → 404
RESOURCE_NOT_FOUND(scoped filter,base.repository.ts:20-30), no existence leak.
2. Edit-after-notification / correction flows
-
Correct a record after a notification was sent (when dispatch
(planned)lands): PATCH works on the same doc id;AttendanceUpdated {changes:[...]}emitted (attendance.service.ts:88-99). - Wrong-date fix: mark correct day; wrong-day record found in S4 day view; PATCH to correct status; no delete endpoint exists (OQ-11) — verify the "holiday/remark" guidance is reachable.
- Undo: UndoBar re-sends previous status; after 4 s it's gone; grid reflects server state after refresh.
3. Timezone & date handling
-
datesent asYYYY-MM-DDin tenant-local calendar day — client never sends UTC-shifted dates (mark-attendance.dto.ts:21; stored as Dateattendance.schema.ts:31-32). - Marking at 23:50 local ≠ next-day doc; at 00:10 local ≠ previous-day doc.
-
checkIn/checkOut ISO-8601 with correct timezone offset survive round-trip
(
attendance.service.ts:32-33). - DST boundary day: month heatmap has 28/29/30/31 days correctly (client month-math); server stores UTC, client displays local.
-
Scheduler context:
attendance-report-daily07:00 UTC (scheduler.service.ts:99-104) — report periods use UTC boundaries; UI must not imply tenant-local "7 AM" semantics.
4. Large-class & performance
-
60+ student class: grid scrolls at 60 fps (0 dropped frames profiled —
00-shared/10 §1);
ListView.builder+itemExtentverified. - Month heatmap (S4, 30 fan-out day fetches) shows skeleton first, fills as days arrive; cache prevents refetch on month switch.
-
Bulk of 60 records: request completes < 5 s (sequential server loop,
attendance.service.ts:48-54); UI shows progress, not freeze. -
GET /attendance/summaryon a full term returns within budget or shows progressive loading (in-memory aggregation,attendance.service.ts:103-113— OQ-8). -
Report job: no request timeout during
queued/processing; polling capped; UI terminal oncompleted|failed(report-job.schema.ts:13-18).
5. Offline marking
-
Airplane mode: cached roster renders,
AppOfflineBannershown, marks queue locally, rows flagged dashed+cloud (00-shared/10 §2; 13_State_Management.md §4). - Kill app offline → relaunch → queue intact (persisted Hive box).
-
Reconnect: auto-flush,
Idempotency-Keyper op, snackbar "Synced N marks". - Retry: idempotent — a duplicate flush does not create duplicates (upsert).
- Offline collision (server has newer data): 409/conflict preview sheet; user chooses overwrite or discard; no silent data loss.
- Offline PATCH correction: blocked (pessimistic policy, 13 §3) with guidance.
6. Idempotent retries & partial failures
- Double-tap submit on chip → one request (anti-double-submit, 00-shared/08 §6).
- Bulk partial failure simulation (server down mid-loop): failed subset retried alone; no duplicates for the succeeded subset.
-
Worker
bulk-importbehavior (OQ-2): per-record failures swallowed → if surfaced in UI later, failure report matches actual saved count (attendance.worker.ts:91-101). - 409 on single mark → grid refresh, no ghost chip.
7. Permissions & roles
-
Teacher (role default
['student.read','attendance.mark','attendance.edit'],role.schema.ts:31): can mark + correct; cannot see S5/S6 reports (noreport.*,role.schema.ts:31) — verify server-side response & hidden UI. -
Parent/student (no attendance perms,
role.schema.ts:55,63): S7 read-only per OQ-7 decision; test 403 handling once server enforces. -
All write endpoints reject requests from tokens lacking
attendance.mark/edit— currently not decorator-enforced (OQ-5): assert current behavior and track. - 403 vs 404: cross-tenant read = 404; same-tenant wrong role = 403 (if enforced) — UI matches (00-shared/06 §5).
8. Biometric integration
-
POST /biometric/ingestwith valid{studentId, deviceId, timestamp}→ 201 log doc; no attendance doc created (current behavior — OQ-3); S8 shows "captured, not applied" honestly. -
Invalid studentId → 400
VALIDATION_ERROR(@IsMongoId,create-biometric-log.dto.ts:7). -
Device status mapping:
active/inactive/offline(biometric-device.schema.ts:7-11); "active but no punches 24 h" warning. -
Biometric-sourced docs (when pipeline
(planned)lands):source:'biometric'(attendance.schema.ts:16-21) displayed in S7 day detail.
9. Accessibility (module additions to 00-shared/09 §12)
- Grid: TalkBack/VoiceOver full marking flow without sight; chips announce status + "double-tap to change"; count bar live region.
- No color-only status anywhere (icons + labels; 00-shared/09:76).
-
Keyboard-only: P/A/L/H/V/D shortcuts,
Escselection exit, focus ring visible. - Text scale 2×: chips collapse gracefully; heatmap readable; no clipped rows.
- Conflict banner announced; undo announced; haptics off in reduced-motion.
10. Localization & formatting
-
Status labels/i18n keys (
att.status.*); date formats viaIntltenant locale;YYYY-MM-DDwire format independent of display format. - Long student names + long remarks do not break chip/row layout (RTL ready).
11. Performance budgets (module)
| Check | Budget |
|---|---|
| Grid first frame (cached) | < 300 ms (00-shared/10 §1) |
| Grid scroll 60 rows | 0 dropped frames |
| Chip mark round-trip feel | pending indicator ≤ 100 ms after tap |
| Month heatmap full render | < 2 s network, skeleton first |
| Report poll cycle | ≤ 5 min cap, no leaks |
12. Release gates (module additions)
-
e2e parity:
p1-operations.e2e-spec.ts:200-228(mark 201, class read 200, invalid status 400) pass against live backend. - Golden tests: grid, chips, heatmap, batch sheet — light/dark × 3 sizes.
- Offline matrix (§5) fully green on mid-range device.
15 — Flutter Implementation Guide (Attendance Module)
- 1. Module folder structure
- 2. Grid performance for 60+ rows
- 3. Offline marking queue
- 4. Repository & networking
- 5. Router
- 6. Widget → cubit wiring (grid page)
- 7. Testing (module additions to
00-shared/11 §12) - 8. Localization & analytics
- 9. Deps delta (none new)
Extends 00-shared/11_Flutter_App_Architecture.md. Focus: grid performance for 60+ rows and the offline marking queue. Forward-looking spec (no client repo exists).
1. Module folder structure
lib/features/attendance/
├── data/
│ ├── dto/attendance_dto.dart # envelope payload mappers
│ ├── dto/bulk_mark_dto.dart
│ ├── dto/summary_dto.dart # {total, summary}
│ ├── dto/report_job_dto.dart # queued/processing/completed/failed
│ ├── models/attendance.dart # Attendance model (status enum, dates)
│ ├── models/roster_row.dart # {student, doc?}
│ ├── models/mark_op.dart # offline queue op
│ └── repositories/
│ ├── attendance_repository.dart # REST + cache
│ └── offline_mark_queue.dart # Hive-backed queue
├── domain/
│ └── mark_semantics.dart # cycle order, conflict detection (pure Dart)
└── presentation/
├── cubit/roster_cubit.dart
├── cubit/marking_cubit.dart
├── cubit/history_cubit.dart
├── cubit/report_cubit.dart
├── pages/roster_page.dart
├── pages/marking_grid_page.dart
├── pages/history_page.dart
├── pages/report_page.dart
├── pages/device_page.dart
└── widgets/ # 07_Component_Library.md components
DTO mapping (00-shared/11 §4): server date → DateTime (tenant-local midnight);
status → sealed enum matching attendance.schema.ts:7-14; source enum
(attendance.schema.ts:16-21) shown only on detail. Never pass DTOs into widgets.
2. Grid performance for 60+ rows
Target: 60 rows, 60 fps, no rebuild storms (00-shared/10 §1, 00-shared/11 §13).
ListView.builder+ fixeditemExtent(56 dp) — no variable-height rows; chips are one line.itemExtentskips layout measurement → cheapest scroll path.constconstructors for row chrome; only the chip + trailing depend on state.- Split widgets, don't rebuild rows:
StatusChipis its ownBlocSelector<MarkingCubit, StatusState, StatusChipState>— a status flip rebuilds one chip, not the list. Row body (name/roll/avatar) isconst-built and never depends on mark state → excluded from rebuild viaSelector. RepaintBoundaryper row — isolates chip animations (pulse, morph) from list painting; prevents repaint storms during rapid taps.- Grid header & MarkedCountBar as
SliverPersistentHeader— sticky without re-layout of rows; counts updated viaBlocBuilderon a counts-only state slice (storeMap<Status,int>precomputed in cubit — never compute inbuild). - Selection model outside rows: selection lives in the cubit (
Set<studentId>); rows render a checkbox overlay only whenselection.isNotEmpty— toggling selection mode rebuilds the batch bar once, not 60 rows (gate withSelectoron mode flag). - Icons: status icons drawn via
Iconwith a cachedIconDatamap (no per-frame asset lookups); chips useAnimatedContainer(cheap) notAnimatedSwitcherinside lists (widget-tree churn); morph toAnimatedSwitcheronly in the popover. - Date keys: every row keyed by
studentId(stable identity forSelectors). - No
setStatein grid page — all state via cubit; page builds only scaffold chrome (layout, filters, batch bar). - Benchmark hook: golden +
WidgetTestertimed build of a 60-row grid (assert < 8 ms row build); profile scroll on mid-range device per release (00-shared/11 §13).
3. Offline marking queue
Persistence: Hive box att.queue keyed {tenantId}:{classId}:{date} (Hive already
the chosen cache — 00-shared/11 §11; no new dep).
OfflineMarkQueue
enqueue(List<MarkOp>) // dedupe by (studentId,date): replace stale ops
pending() -> List<MarkOp> // FIFO
flush(AppDio) // -> bulk POST chunks ≤100, Idempotency-Key: opId
remove(opId); retryBackoff(); conflicted(opId, serverDoc)
Rules (13_State_Management.md §4):
- Capture on network error or
ConnectivityCubitoffline; row chip → dashed + cloud icon. - Flush on reconnect: sequential ops; per-op success removes; failure keeps with backoff (max 5 attempts → "N marks waiting — review" UI).
Idempotency-Key: opIdheader on every flushed POST (server upsert already makes retries converge — header dedupes events,00-shared/07 §9).- 409 on flush → fetch
GET /attendance/:id... (doc id from conflict response or bystudentId+date) → conflict preview sheet; user overwrite/discard; never silent. - Queue survives session expiry: preserved, flushed after re-login.
4. Repository & networking
AttendanceRepository:mark,bulkMark,classByDate(classId, date),studentHistory(studentId, start?, end?),summary(classId, start, end),byId(id),update(id, dto)— all return models; errors →ApiException(00-shared/06 §2).- Cache: roster rows 24 h; day marks 5 min SWR (
00-shared/06 §3.3); month daysatt.days:{classId}:{date}5 min. - WS: subscribe
attendance.changedfor currentclassId+date→ invalidate day cache, refresh marks (silent, no grid reset if user has unsaved edits — merge, don't clobber). - Timeouts: grid reads 15 s; bulk 30 s (large payloads); report poll 15 s.
5. Router
Routes (04_Information_Architecture.md §4) via go_router; guards
permissionGuard('attendance.mark') etc. Deep link studylyon://attendance/:date →
S7 day view (parent alert, (planned) dispatch) — date parsed tenant-local.
6. Widget → cubit wiring (grid page)
MarkingGridPage
├─ BlocProvider<MarkingCubit> (created per class+date; disposed on pop)
├─ SliverPersistentHeader (MarkedCountBar + StatusFilterChips) [BlocBuilder: counts slice]
├─ SliverList.builder itemExtent 56
│ └─ MarkingRow
│ ├─ const RowBody (name, roll) [never rebuilds]
│ └─ BlocSelector<StatusChip> (cycle/popover) [rebuilds on its own status]
└─ AnimatedSwitcher (batch bar / UndoBar / FAB)
Grid page itself does not rebuild on row state changes.
7. Testing (module additions to 00-shared/11 §12)
- Unit:
mark_semantics(cycle order, conflict detection),OfflineMarkQueue(dedupe, flush, backoff, idempotency), DTO mappers (date tz), summary math (mirrorattendance.service.ts:103-113). - Widget: grid 3-state (loading/error/success), optimistic flip + rollback, batch bar, conflict banner, undo.
- Golden: grid (60 rows), chips all statuses, heatmap month, batch sheet —
light/dark × 3 sizes (
00-shared/10 §9). - Integration: login → timetable → mark attendance flow (
00-shared/11 §12); offline: mark 5 → airplane → reconnect → flush → server has 5 docs (upsert check). - E2E parity:
p1-operations.e2e-spec.ts:200-228scenarios against live backend.
8. Localization & analytics
.arbkeys:att.status.*,att.grid.*(marked/unmarked counts, sweep, overwrite banner, conflict, offline queued, sync done, device statuses). Dates viaIntltenant locale; wire format alwaysYYYY-MM-DD.- Analytics
(proposed)events (00-shared/10 §8):attendance.grid.open,attendance.mark.status(studentId excluded — PII guard),attendance.batch.apply,attendance.offline.queue|flush,attendance.report.view,attendance.device.view.
9. Deps delta (none new)
Uses existing stack: flutter_bloc, dio, go_router, get_it, hive,
connectivity_plus, intl, fl_chart (donut), secure_storage. No new packages.
01 — Product Overview (Audit Module)
- 1. Purpose
- 2. Business goals
- 3. User goals
- 4. Stakeholders
- 5. Why this exists
- 6. Dependencies
- 7. Success metrics
- 8. Edge cases
- 9. Assumptions (module)
- 10. Open questions (module-level; global ledger in 00-shared/12)
- 11. Glossary (this module)
StudyLyon — multi-tenant ERP / School Management API. This package designs the Audit module client (Flutter, forward-looking spec) against the implemented NestJS backend. Every endpoint, filter, field, event, and retention rule is derived directly from
src/modules/audit/**,src/events/**,src/modules/ws/**,src/infrastructure/**, andstudylyon-blueprint/03-Database/AUDITING.md+DATA_RETENTION.md. No feature is invented; gaps are flagged in the Assumptions & Open Questions section.
1. Purpose
Audit is StudyLyon's immutable, append-only record of every business action. It is the
system of record for compliance, forensic analysis, dispute resolution, and tenant security
review. Every domain event that crosses the in-process EventBus is captured into
audit_logs by the AuditHandler wildcard subscription and can be read back through a
single read-only endpoint.
| Responsibility | Source |
|---|---|
Capture every domain event into audit_logs (wildcard onAny subscription) | audit.handler.ts:18-25 |
Mask secrets (passwordHash, totpSecret, refreshToken, accessToken, token, password) before storage | audit.service.ts:5-12, 14-23 |
| Append-only persistence (create/find/count only; no update/delete repo methods) | audit-log.repository.ts:6, 15-28 |
Read endpoint with filters: action, entityType, actorId + page/limit | audit.controller.ts:17-34 |
| Tenant isolation on every query (tenantId from request context, never from body) | audit.controller.ts:30, audit.service.ts:45-48 |
Action vocabulary = emitted eventType strings (PascalCase, e.g. UserCreated, StudentUpdated) | audit.handler.ts:36; emitters e.g. users.service.ts:65,146,177 |
| Realtime delivery of the same events to the tenant room via WS bridge | ws-bridge.service.ts:16-22 |
| Retention policy: 7 years minimum, immutable, no auto-delete (blueprint) | DATA_RETENTION.md:29 |
Dedicated audit-write BullMQ queue declared; worker is a no-op today | queue.constants.ts:11, system.worker.ts:5-13 |
2. Business goals
| Goal | Measure |
|---|---|
| Legally-defensible history | Every business action produces one append-only entry; records never updated/deleted (AUDITING.md:11-12) |
| Compliance retention | Audit logs retained 7 years minimum, outliving tenant purge (DATA_RETENTION.md:29, 54) |
| Immutability by construction | No update/delete paths in code (audit-log.repository.ts:6); PLAN 19.3 (PLAN.md:191) |
| Tenant isolation | Every entry carries tenantId; queries always scoped (AUDITING.md:67-72, audit.service.ts:45) |
| Zero secret leakage | Sensitive fields masked to '***' at write time (audit.service.ts:20) |
| Non-blocking writes | Audit persistence is async in-process (event handler, not request path) (audit.handler.ts:7-9; AUDITING.md:94) |
| Queryability | Read endpoint filterable by action / entity type / actor (audit.controller.ts:22-29) |
3. User goals
- Org Admin: "show me exactly what happened with this student/user — who did it and when."
- Compliance officer / auditor: "export a complete, tamper-proof trail for an audit window or a dispute."
- Platform admin: "verify no cross-tenant action occurred" (platform tooling
(planned)— see OQ-3). - Support/QA: correlate an incident using
correlationIdacross requests, queues, and audit entries.
4. Stakeholders
Tenant admins (primary readers), compliance officers and external auditors (export, review),
platform operations (cross-tenant oversight (planned)), support (incident correlation via
correlationId), security review board, QA + design + engineering.
5. Why this exists
Schools handle children, fees, and credentials; disputes and inspections are routine.
AUDITING.md makes audit a first-class architectural concern: every CUD + auth + config
action is captured, immutable, tenant-scoped, and retained longest of any collection. The
backend implements the capture + read pipeline today; the client must present it without
ever implying entries can be edited, deleted, or fabricated.
6. Dependencies
| Dependency | Role | Source |
|---|---|---|
EventBus (in-process) | wildcard '*' emit → AuditHandler.onAny capture | event-bus.service.ts:11-14, 20-22; audit.handler.ts:18 |
DomainEvent contract | eventType, tenantId, actorId, occurredAt, correlationId, payload | domain-event.interface.ts:1-8 |
| Emitting modules (users, students, staff, teachers, parents, auth, fees, crm, homework, results, attendance, …) | produce the action vocabulary + payloads that become after snapshots | e.g. users.service.ts:64-76 |
WsGateway + WsBridge | every domain event broadcast to tenant:{tenantId} room → realtime append source for the list | ws-bridge.service.ts:16-22; ws.gateway.ts:50 |
BullMQ audit-write queue | declared + routed (subset of events) but no-op worker — entries are persisted by the in-process handler, not the queue | queue.constants.ts:11; event-queue-map.ts:8-39; system.worker.ts:9-12 |
RBAC audit.read permission | exists in ALL_PERMISSIONS; granted to org_admin; not yet enforced on the controller (Phase-5 (planned)) | permissions.constants.ts:54; role.schema.ts:23; audit.controller.ts:9; docs/IMPLEMENTATION_PLAN.md:241 |
| Global guards | RateLimitGuard (api tier 100/min default) → JwtAuthGuard → RbacGuard | app.module.ts:129-133; rate-limit.constants.ts:6 |
| Mongo collection | audit_logs (+ indexes, listed in AUDITING.md:39 and schema) | audit-log.schema.ts:13, 60-63 |
7. Success metrics
- Every emitted domain event produces exactly one
audit_logsdoc (dedupe bycorrelationId/_idon client; no duplicates in list). - Sensitive keys never appear in any API response (masked
'***'at write,audit.service.ts:20). - List page renders < 300 ms (cached) / ≤ 2 s (network) with default
limit=50(audit.controller.ts:20). - Filtering by
action/actorIdreturns correct scoped results;entityTypefilter behaves as documented (see OQ-1). - Realtime append (WS) delivers new entries within ~1 s of the source action without duplicates.
- Zero client paths that suggest edit/delete/export of entries that the API cannot perform (append-only honesty).
8. Edge cases
- No detail endpoint:
GET /audit-logs/:iddoes not exist — detail is rendered from the list payload (OQ-4). - Pagination shape deviation: service returns
{data, total}withoutmeta→ the envelope interceptor treats it as a non-paginated payload; client must readdata.data[]+data.total(OQ-2, 12_API_Mapping). - Empty resource metadata: most emitters do not set
entityType/entityIdin payloads today →entityTypefilter matches nothing for most actions (OQ-1). - No date-range filter: blueprint promises "query by date range" (
AUDITING.md:86) but nofrom/toparams exist —(planned). - No search/sort:
qandsortparams from the shared convention (00-shared/07 §5) are not supported; sort is fixedoccurredAtdesc (audit-log.repository.ts:23). - TTL contradiction:
migrate.ts:17-23creates a 90-day TTL index onoccurredAt, contradicting the 7-year retention policy (OQ-6). - Audit reads are unauthenticated-role-broad: controller is
JwtAuthGuardonly — any logged-in user (evenstudent,permissions: []) can read the full tenant audit log (OQ-5). - Unbounded
limit: no max clamp —limit=10000is accepted (OQ-7). - WS payloads unmasked:
WsBridgebroadcasts rawpayload(may contain secrets before the masked copy is written); clients must not render it verbatim (OQ-8). - Non-numeric
page/limit(?page=abc) →Number()→NaN→ MongoCastError→ 400VALIDATION_ERROR(http-exception.filter.ts:47-55).
9. Assumptions (module)
- Forward-looking client: backend is complete for capture + list-read; this package specs the UI. The PRD puts native mobile out of Phase 1 (read-only companion in Phase 3 — 00-shared/12 A1); these docs specify the full responsive client anyway (A2: same design system serves web/desktop).
- Audit is an admin-dense surface — desktop/tablet table layout is the primary target; phone is a compact list.
- Actor display names are not in the response (
actorIdonly). Resolving names requires a client-side join againstGET /users(proposed)—actorIdfilter is exact-id matching (audit.controller.ts:24). - "Export" exists in the blueprint (
AUDITING.md:87: CSV/PDF, streamed, itself an audited action) but no endpoint exists → export UI is(planned). - Realtime append is possible today because
WsBridgeforwards every domain event (the same set the handler persists) to the tenant room (ws-bridge.service.ts:16-22).
10. Open questions (module-level; global ledger in 00-shared/12)
| # | Item | Impact |
|---|---|---|
| OQ-1 | Emitters rarely populate entityType/entityId/before in payloads (e.g. users.service.ts:70-75 sends userId only). True "resource" filtering and before/after diffs are effectively absent from today's data. Add entityType/entityId/before to emitters? | Filter UX, diff view |
| OQ-2 | GET /audit-logs returns data:{data:[], total} (no meta) — deviates from the shared paginated envelope. Fix service to return meta (buildPaginationMeta)? | Client parsing |
| OQ-3 | Platform/tenantId:null entries and actorType: platform promised in AUDITING.md:70-71 are not implemented (enum is `user | system |
| OQ-4 | No entry-detail endpoint — deep links to a single entry can't refetch. Add GET /audit-logs/:id? | Detail screen architecture |
| OQ-5 | Controller is JwtAuthGuard only — audit.read defined but unenforced (audit.controller.ts:9); RBAC docs flag the same (design-docs/rbac/05:94-96). Phase-5 permission audit (planned) (docs/IMPLEMENTATION_PLAN.md:241). Client gates on audit.read regardless. | Route gating |
| OQ-6 | migrate.ts:23 creates a 90-day TTL on audit_logs.occurredAt — destroys the 7-year compliance retention (DATA_RETENTION.md:29). Confirm: TTL must be removed or replaced with archive job. | Retention UX, data loss risk |
| OQ-7 | limit unbounded (default 50, audit.controller.ts:20); no clamp to shared max 100 (00-shared/07 §5). | List UX, perf |
| OQ-8 | WS broadcast carries raw, unmasked payload (ws-bridge.service.ts:17-21) while stored docs are masked. If UI renders realtime entries, secret-bearing payloads could surface. Mask at bridge or filter client-side. | Realtime view safety |
11. Glossary (this module)
| Term | Meaning |
|---|---|
Audit entry / AuditLog | one immutable doc in audit_logs (audit-log.schema.ts:13) |
action | the eventType string of the source event, e.g. UserCreated, StudentUpdated (audit.handler.ts:36) |
actorId / actorType | who performed the action; type is always user today (audit.handler.ts:35) |
before / after | masked snapshots; before is set only if the emitter provides it (rarely), after falls back to the full payload (audit.handler.ts:42-43) |
correlationId | end-to-end trace id linking request → queue job → audit entry (domain-event.interface.ts:6) |
occurredAt | authoritative timestamp (ISO-8601 UTC) of the action — not createdAt (schema uses timestamps: false, audit-log.schema.ts:13) |
| append-only | no update/delete surface anywhere in the module (audit-log.repository.ts:6, PLAN 19.3) |
| Realtime append | WS push of domain events to tenant:{tenantId} room (ws.gateway.ts:50, ws-bridge.service.ts:16) |
| Envelope | {success,message,data,meta?,timestamp,requestId} (response-envelope.interceptor.ts:11-18) |
02 — User Personas (Audit Module)
- 1. Org Admin (
org_admin) — primary user - 2. Compliance Officer (custom role,
audit.read+ report perms) - 3. External Auditor / Inspector (invited access
(proposed)) - 4. Platform Admin (
platform_admin, cross-tenant) - 5. Support / QA Engineer (internal)
- 6. Role → audit-appearance matrix
Roles, goals, permissions, and expected behaviour for every consumer of the Audit module. Permission values are exact from
rbac/schemas/role.schema.ts(DEFAULT_ROLES) andrbac/permissions.constants.ts:54(audit.read). Critical nuance: the server enforces onlyJwtAuthGuardonGET /audit-logs(audit.controller.ts:9) —audit.readis metadata today; the client gates on it anyway (Phase-5(planned),docs/IMPLEMENTATION_PLAN.md:241; OQ-5 in 01).
1. Org Admin (org_admin) — primary user
| Attribute | Value |
|---|---|
| Permissions | ALL_PERMISSIONS incl. audit.read (role.schema.ts:23) |
| Frequency | Daily to weekly; desktop/tablet during school operations |
| Goal | "What changed, by whom, when?" — dispute resolution (fees, marks, attendance), staff oversight, verifying destructive actions |
| Pain points | Can't find the exact entry (no search/date filter today), actor names not in the response, no export |
| Core job | Open audit list → filter by action/actorId → tap an entry → read who/when/what changed |
| Expected | Realtime new entries appear (WS); occurredAt shown in local time; immutable entries — no edit/delete affordances |
| Mental model | "A tamper-proof diary of the school's system." |
2. Compliance Officer (custom role, audit.read + report perms)
| Attribute | Value |
|---|---|
| Permissions | Custom role; must include audit.read (permissions.constants.ts:54); roles are per-tenant (role.schema.ts:68-85) |
| Frequency | Monthly/quarterly compliance windows; ad-hoc on disputes |
| Goal | Produce a defensible record for a period: who touched student/fee records, when, with what before/after state |
| Pain points | No date-range filter ((planned)), no export ((planned)), before snapshots mostly absent (OQ-1) |
| Expected | Filter + review + export (CSV/PDF, AUDITING.md:87) (planned); the audit itself logs the export action per blueprint |
| Mental model | "Evidence for an inspection." |
3. External Auditor / Inspector (invited access (proposed))
| Attribute | Value |
|---|---|
| Access | Not a first-class identity today — backend has no auditor persona (only `user |
| Goal | Verify no unauthorized change occurred; spot-check entries against school records |
| Expected | Same list/detail UI; must be able to prove integrity ("never modified") — UI communicates immutability (no edit/delete affordances, timestamp authority) |
| Mental model | "Show me the paper trail." |
4. Platform Admin (platform_admin, cross-tenant)
| Attribute | Value |
|---|---|
| Permissions | permissions: [] but JwtAuthGuard sets isPlatformAdmin=true when role present (jwt-auth.guard.ts:54) — bypasses seed |
| Frequency | On-demand support / incident response |
| Goal | Cross-tenant visibility: did tenant X see tenant Y's data? correlate incidents via correlationId |
| Restrictions | Platform audit entries (tenantId: null, actorType: platform) are not implemented (AUDITING.md:70-71 vs audit-log.schema.ts:7-11) — cross-tenant audit UI is (planned) (OQ-3) |
| Expected | Read the tenant's audit log with a tenant-scoped token; per-tenant navigation (planned) |
| Mental model | "Look under the hood across tenants." |
5. Support / QA Engineer (internal)
| Attribute | Value |
|---|---|
| Access | Tenant token with audit.read (or dev tooling) |
| Goal | Reconstruct a failed flow: follow correlationId (audit-log.schema.ts:51-52) from an error requestId to the audit entry |
| Expected | correlationId visible on entry detail; copyable for issue reports |
| Mental model | "The audit log is the debug trail." |
6. Role → audit-appearance matrix
| Value | org_admin | compliance officer | auditor (proposed) | platform_admin | teacher/staff/accountant/parent/student |
|---|---|---|---|---|---|
audit.read permission | ✓ (role.schema.ts:23) | via custom role | via custom role | n/a (platform flag) | ✗ (not in any seed role, role.schema.ts:31-42) |
| Server guard today | JWT only (audit.controller.ts:9) — no perm check | same | same | same | JWT only — currently can read (OQ-5) |
| Client route gate | ✓ | ✓ | ✓ | ✓ | hidden |
| Filter + list | ✓ | ✓ | ✓ | per-tenant (planned) | — |
| Entry detail / diff | ✓ | ✓ | ✓ | — | — |
| Export | (planned) (AUDITING.md:87) | primary need | primary need | — | — |
| Realtime append | ✓ (WS) | ✓ | ✓ | — | — |
Note: until the Phase-5 permission audit lands (docs/IMPLEMENTATION_PLAN.md:241), the
server allows any authenticated user to read the tenant audit trail; the client hides
the surface unless audit.read is present. Flag to security owner (OQ-5).
03 — User Journeys (Audit Module)
- 1. Browse the audit log
- 2. Filter by actor / action / resource
- 3. Search (free text) —
(planned) - 4. Inspect an entry — before/after diff
- 5. Realtime append (WS)
- 6. Export audit trail —
(planned)
End-to-end journeys for the Audit module computed from
audit.controller.ts+audit.service.ts+audit.handler.ts+ws-bridge.service.ts. Each journey: entry, intent, decision points, system responses, loading, failures, recovery, exit.(planned)/(proposed)marks per global rules. All requests:GET /api/v1/audit-logs, Bearer JWT, envelope per 00-shared/07.
1. Browse the audit log
entry: /settings/audit from Settings hub, or "Access audit" entry from RBAC
intent: see recent tenant activity
sequenceDiagram
actor U as Admin
participant L as AuditListPage
participant C as AuditCubit
participant R as AuditRepository
participant API as GET /audit-logs
U->>L: open audit log
L->>C: load(page=1, limit=50)
C->>R: query(filters={})
R->>API: ?page=1&limit=50
alt success
API-->>R: envelope.data = {data:[...], total:N} (audit.service.ts:41-50)
R-->>C: entries + total
C-->>L: render table (desktop) / cards (phone)
else 401
API-->>R: UNAUTHENTICATED
C-->>L: session-expiry flow (00-shared/06 §3.6)
else 429
API-->>R: RATE_LIMITED (api tier 100/min, rate-limit.constants.ts:6)
L-->>U: countdown chip, no auto-retry
else network
L-->>U: AppErrorState + Retry
end
L-->>U: scroll → Load more (next page) until total reached
- Loading:
AppSkeletonrows ≤ 200 ms → content; default page size 50 (audit.controller.ts:20). - Entry: row tap → detail (from in-memory entry — no detail endpoint, OQ-4).
- Exit: back to Settings; realtime WS appends visible on list (journey 5).
2. Filter by actor / action / resource
entry: filter bar on audit list
intent: narrow the trail to one actor, one action, or one resource type
sequenceDiagram
actor U as Admin
participant L as AuditListPage
participant C as AuditCubit
participant API as GET /audit-logs
U->>L: pick Action = "StudentUpdated"
L->>C: changeFilter(action: "StudentUpdated")
C->>C: reset page=1
C->>API: ?page=1&limit=50&action=StudentUpdated
API-->>C: data:[entries], total
Note over U,API: filters combine (AND): action + entityType + actorId (audit.controller.ts:26-29)
alt entityType filter chosen
Note over U,API: entityType matches only entries whose payload carried entityType — most emitters don't set it (OQ-1); show helper copy
end
U->>L: "Clear filters" → resets all three → reload unfiltered
- Filter controls: three
AppDropdowns (Action, Entity type, Actor) — all optional, AND-combined (audit.controller.ts:26-29). - Recovery: empty result →
AppEmptyState"No activity matches these filters" + Clear button. - State: filter selection resets pagination to page 1;
totalrefreshes.
3. Search (free text) — (planned)
entry: AppSearchBar on audit list
intent: find an entry by entity id, actor name, or event payload value
- Server today: no
qparam onGET /audit-logs(audit.controller.ts:19-25) — shared conventionsort/qunsupported (OQ-2 in00-shared;12_API_Mapping). (planned)client-side: filter current page by visible fields (action, entityType, actorId, id) as an interim; full server search requires a backendq(regex across payload) — flag to backend team.
4. Inspect an entry — before/after diff
sequenceDiagram
actor U as Admin
participant L as AuditListPage
participant D as AuditDetailView
U->>L: tap row (action=UserUpdated, actor=A.K., 10:32)
L-->>D: pass in-memory AuditEntry (no refetch — no :id endpoint, OQ-4)
D-->>U: header: action chip + actor + occurredAt (local tz) + correlationId
Note over D,U: body = AuditDiffView
alt emitter provided before/after
D-->>U: changed/added/removed rows (OQ-1: rare today)
else after only (common: after = payload, audit.handler.ts:43)
D-->>U: "Snapshot after action" — payload rendered as JSON tree (e.g. {userId, changes:[...]}, users.service.ts:70-75)
else neither
D-->>U: "No snapshot captured for this action" (empty diff)
end
U->>D: expand nested JSON value / copy JSON (SelectableText)
U->>U: back → list keeps scroll + filters
- Immutability cues: no edit/delete buttons anywhere on the detail view (append-only,
audit-log.repository.ts:6); footnote "This record cannot be modified." - Diff math is client-side: server stores full
before/aftermaps; the client computes changed/added/removed keys.
5. Realtime append (WS)
entry: audit list open while another user performs an action
intent: watch the trail update without manual refresh
sequenceDiagram
participant S as Source service (e.g. users)
participant B as EventBus
participant H as AuditHandler
participant DB as audit_logs
participant W as WsBridge
participant C as AuditCubit (connected)
S->>B: emit(UserUpdated, {tenantId, actorId, payload})
B-->>H: onAny(event) → write masked entry (audit.handler.ts:18-45)
B-->>W: onAny(event) → broadcast to tenant room (ws-bridge.service.ts:16-22)
W-->>C: WS topic "UserUpdated" {eventType, occurredAt, payload}
C-->>C: prepend entry (dedupe by _id/correlationId; respect active filters)
C-->>U: banner "1 new entry" → tap to expand
- WS auth: bearer token in handshake (
ws.gateway.ts:37-40); client joinstenant:{tenantId}automatically (ws.gateway.ts:50). - Filter-aware: realtime entries are appended only if they match the active filter set; otherwise the banner shows "New activity — refresh filters".
- Safety: broadcast payload is unmasked (OQ-8) — the client never renders broadcast payload fields verbatim; it uses the REST snapshot on expand.
6. Export audit trail — (planned)
entry: list overflow menu / detail menu → "Export"
intent: CSV/PDF of the filtered result set for compliance (AUDITING.md:87)
sequenceDiagram
actor U as Admin
participant L as AuditListPage
participant S as ExportSheet
U->>L: overflow → Export
L->>S: open ExportSheet (format, scope = current filters + pages, timezone)
S->>U: confirm → submit (planned endpoint; export itself is an audited action per AUDITING.md:87)
alt endpoint exists (future)
S-->>U: job queued (REPORT_GENERATE, queue.constants.ts:10) → status + download
else today
S-->>U: disabled state with note "(planned)" — no backend export endpoint
end
- Client scope today: none — the export surface is speced but gated until the backend lands (OQ/planned).
Abandonment & exit rules (all): back = restore previous scroll/filters; timeout = WS reconnect triggers silent refetch; permission denial (no audit.read) = route hidden + 403 screen (00-shared/05 §8); offline = last-good cache + AppOfflineBanner, WS buffered → refetch on reconnect.
04 — Information Architecture (Audit Module)
- 1. Placement in the shell
- 2. Route table (module-owned; extends
00-shared/05 §4) - 3. Screen hierarchy
- 4. Modal hierarchy (module)
- 5. Screen relationships
- 6. Nested nav
- 7. Quick actions / FAB / context menus
- 8. Deep links (proposed routing table)
- 9. Accessibility of IA
- 10. Peripheral: what Audit owns vs shared
Where Audit screens live in the shell (00-shared/05). Audit is an authenticated, admin-gated surface inside the Settings area. Everything derives from the single read endpoint
GET /audit-logs(audit.controller.ts:10) and the shared shell.
1. Placement in the shell
AppShell
└─ Settings (/settings) [settings.read surface, admin]
├─ /settings/audit ← Audit log (this module, canonical route)
│ └─ /settings/audit/:id ← Audit entry detail (in-memory; no refetch, OQ-4)
└─ /settings/access-audit ← Permission audit preset (RBAC module,
design-docs/rbac/05 S6) — same endpoint
- Audit is a read-only sibling of RBAC's "Access audit" view. Both query
GET /audit-logs(audit.controller.ts:17-34); the RBAC one pre-filters on RBAC actions and is documented indesign-docs/rbac/04-08. Canonical detail + diff live in this module. Unify under one route(proposed)(OQ-4/route OQ). - Gate: client requires
audit.read(permissions.constants.ts:54) even though the server currently only requires a JWT (OQ-5).
2. Route table (module-owned; extends 00-shared/05 §4)
| Route | Screen | Auth | Permission (client) | Source API |
|---|---|---|---|---|
/settings/audit | Audit log list + filter bar | JWT | audit.read | GET /audit-logs (audit.controller.ts:17) |
/settings/audit/:id | Audit entry detail (diff view) | JWT | audit.read | in-memory entry from list (no :id endpoint, OQ-4) |
/settings/audit/export | Export sheet (planned) | JWT | audit.read | none yet (AUDITING.md:87) |
/settings/access-audit | Permission audit preset (RBAC module) | JWT | audit.read | same endpoint, RBAC action presets |
3. Screen hierarchy
flowchart TD
SET[/settings/] --> AUD[/settings/audit/]
AUD --> ENT[/settings/audit/:id/]
AUD --> EXP[/settings/audit/export/ (planned)]
AUD --> RBA[/settings/access-audit/ (RBAC module, same data)]
- List is the hub: filter bar → results → row tap → detail (push on phone, master-detail pane on tablet/desktop ≥ 840 px,
00-shared/05 §3). - Detail returns preserving scroll + filters (list state kept alive).
- Export is a modal sheet from the list overflow, not a standalone flow today.
4. Modal hierarchy (module)
Following 00-shared/05 §5:
- Snackbar/toast — "New entry", "Copied JSON", "Filters cleared".
- Bottom sheet — realtime "New activity" banner expansion, JSON value inspector (nested values), export format picker
(planned). - Dialog — none destructive (module is read-only; no delete/confirm flows exist).
- Full-screen page — entry detail on phone (JSON tree needs space); list on desktop.
- Loader —
AppSkeletonrows for list; no full-screen loader.
5. Screen relationships
- List ↔ Detail: master-detail on ≥ 840 px (detail pane updates on row select); push-on-top on phone.
- Filter bar state lives in the list cubit; changing filters resets to page 1 (
03 §2). - Realtime WS appends integrate into the list (journey 5) — no separate screen.
- RBAC
access-auditreuses the same list component with a preset filter set.
6. Nested nav
- Settings uses
StatefulShellBranch(00-shared/05 §3); audit list keeps scroll/filter state across shell switches. - Breadcrumbs (desktop):
Settings › Audit log › Entryon detail.
7. Quick actions / FAB / context menus
- No FAB (read-only module; nothing to create).
- List row
AppMenu: View entry, Copy entry ID, Export(planned). - Keyboard (desktop):
/orCtrl+Ffocuses filter bar,Escclears filters, arrows navigate table rows,Enteropens detail,g then i(entry id) copy(proposed).
8. Deep links (proposed routing table)
| Pattern | Target | Note |
|---|---|---|
studylyon://settings/audit | Audit list | JWT + audit.read |
studylyon://settings/audit/:id | Entry detail | in-memory lookup; fallback: list + match by id on next page load (no :id endpoint, OQ-4) |
studylyon://settings/access-audit | Permission audit preset | RBAC entry |
9. Accessibility of IA
- One
headlineMediumper screen; filter bar before results in focus order; table exposes column headers via semantics (00-shared/09 §7); live region announces "N entries" after filter changes and realtime appends.
10. Peripheral: what Audit owns vs shared
- Audit owns: list + filter bar, entry detail + diff, export sheet
(planned). - Shared owns: 403/404/500/offline screens, session-expiry overlay, notification center — preserved from
00-shared/05 §8.
05 — Screen Inventory (Audit Module)
- Legend
- 1. Audit Log List (
/settings/audit) - 2. Audit Filter Bar (part of list screen)
- 3. Audit Entry Detail (
/settings/audit/:id) - 4. Export Sheet (
/settings/audit/export)(planned) - 5. Entry Timeline
(proposed)— optional richer view - Shared components used
- Keyboard, landscape, tablet, desktop
Every screen of the Audit module, its intent, route, composition, states, permissions, platform behaviour and events. Authoritative components in 00-shared/03; module-specific components in 07_Component_Library.md. Data fields are exact from
audit-log.schema.ts:13-56.
Legend
States = idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission.
Analytics events follow {module}.{screen}.{action} (proposed; SDK open — 00-shared/10 §8).
1. Audit Log List (/settings/audit)
| Field | Detail |
|---|---|
| Purpose | Browse + filter the tenant's immutable action trail |
| Entry | Settings → Audit log; RBAC "Access audit"; deep link |
| Exit | row → detail; back → Settings; export (planned) |
| Source | GET /api/v1/audit-logs?page&limit&action&entityType&actorId (audit.controller.ts:19-25) |
| Composition | AppBar "Audit log" (+ count total); AuditFilterBar; result list: desktop/tablet = AuditTable (Time, Action, Actor, Resource, Context), phone = AuditEntryRow cards; footer "N of M" + Load more |
| States | loading (AppSkeleton rows), success, empty (AppEmptyState "No activity recorded" — also when total===0), error (AppErrorState by code), permission (route hidden w/o audit.read), offline (AppOfflineBanner + cached list) |
| Realtime | WS appends (journey 5) → prepend + banner "1 new entry" |
| Pull-to-refresh | RefreshIndicator → load(page=1) bypassing cache |
| Analytics | audit.list.view, audit.list.filter, audit.list.clear, audit.list.load_more, audit.list.refresh, audit.list.realtime_append (proposed) |
Data per row (exact schema): _id, action, actorId, actorType, entityType?,
entityId?, occurredAt, ipAddress?, device?, browser?, userAgent?, before?,
after?, correlationId?, metadata? (audit-log.schema.ts:15-55) plus inherited
tenantId, version, isDeleted, createdBy/updatedBy/deletedBy (base.schema.ts:8-34)
— inherited markers are not displayed.
2. Audit Filter Bar (part of list screen)
| Field | Detail |
|---|---|
| Purpose | AND-combined narrowing: action / entity type / actor (audit.controller.ts:26-29) |
| Controls | AppDropdown Action (free list from data), AppDropdown Entity type, AppDropdown/AppTextField Actor (id or name→id), Clear button, (planned) date range + search |
| Behaviour | any change → page=1 + reload; clear → unfiltered reload |
| States | default (all empty), active (chips with values + clear), empty-result hint when entityType chosen (see OQ-1) |
| Analytics | audit.list.filter with values |
3. Audit Entry Detail (/settings/audit/:id)
| Field | Detail |
|---|---|
| Purpose | Inspect one entry: who/what/when + before/after snapshot |
| Entry | row tap; deep link (in-memory lookup, OQ-4) |
| Exit | back → list (state preserved) |
| Composition | Header: AuditActionChip (action), actor line (actorId mono + name if resolved), occurredAt local-time + UTC tooltip, correlationId (copy), meta actorType; body: AuditDiffView (before/after) or JsonTreeView (after = payload, audit.handler.ts:43) or empty state; footer: immutability note |
| States | entry found / not found (deep link with no match → empty state + "back to list"), loading n/a (in-memory) |
| Actions | Copy ID, Copy JSON, Expand nested values, Export (planned) |
| Analytics | audit.detail.view, audit.detail.copy_json, audit.detail.expand (proposed) |
4. Export Sheet (/settings/audit/export) (planned)
| Field | Detail |
|---|---|
| Purpose | CSV/PDF export of current filter scope (blueprint: streamed, itself an audited action — AUDITING.md:87) |
| Entry | list overflow menu |
| Composition | Format radio (CSV/PDF), scope summary (filters + pages), timezone selector, CTA "Export" |
| States | disabled with note — no backend endpoint (gated until backend lands) |
| Analytics | audit.export.request (proposed) |
5. Entry Timeline (proposed) — optional richer view
Grouped-by-resource timeline (all entries for one entityId/correlationId in reverse
chronology). Requires backend ?entityId= filter support (OQ-1) or client-side grouping of
the loaded set only. (proposed) — not in code.
Shared components used
AppSkeleton, AppEmptyState, AppErrorState, AppOfflineBanner, AppSnackbar,
AppDropdown, AppSearchBar, AppChips, AppCard, AppListTile, AppMenu,
AppBottomSheet, AppRefreshIndicator, AppBadge, AppButton. Module-specific:
AuditTable, AuditEntryRow, AuditActionChip, AuditFilterBar, AuditDiffView,
JsonTreeView, EntryTimeline (proposed) — defined in 07_Component_Library.md.
Keyboard, landscape, tablet, desktop
- Desktop: full-width table with sticky header;
/focuses filter; arrows + Enter navigate rows. - Tablet ≥ 840 px: master-detail (list left, detail right).
- Phone: cards, single pane; landscape scrolls.
06 — Screen Specifications (Audit Module)
- 1. Audit Log List
/settings/audit - 2. Audit Filter Bar
- 3. Audit Entry Detail
/settings/audit/:id - 4. Export Sheet
/settings/audit/export(planned) - 5. Entry Timeline
(proposed) - Global micro-interaction & motion notes (all screens)
Production-grade specs: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens:
Sp= 00-shared/02 §3,Ty= typography §2,El= elevation §5,R= radius §4,Mo= motion §6. All components reference 00-shared/03. Data contract in 12_API_Mapping.md.
1. Audit Log List /settings/audit
Chrome: AppBar (title "Audit log", subtitle count from total when loaded); body =
filter bar + result area; horizontal padding 16 (phone) / 24 (tablet+). No FAB (read-only).
Layout (phone portrait):
AppBar "Audit log" [count badge]
AuditFilterBar [Action ▼] [Entity ▼] [Actor] [Clear]
List (AuditEntryRow cards):
Card: ActionChip "StudentUpdated" · 10:32
Actor: A. Kumar (u_8f2a…) · entity: student_9f1
context: correlationId 9a1… (mono bodySmall)
⋮
Footer: "Showing 50 of 1,234" [Load more]
Layout (desktop ≥ 1200 px) — AuditTable (primary target):
| Time | Action | Actor | Entity | Context |
|---|---|---|---|---|
| 02 Aug 2026, 10:32:14 | StudentUpdated | A. Kumar | student_9f1… | corr 9a1… |
| 02 Aug 2026, 10:30:02 | UserLoggedIn | A. Kumar | — | corr 8c2… |
- Sticky header row (
e-2); hover row highlight (m-fast); row tap → detail. - Column widths: Time 180, Action 200, Actor 200, Entity 200, Context flexible (mono).
- Desktop shows both local-time and UTC in the Time column tooltip (server sends ISO UTC
occurredAt,audit-log.schema.ts:42-43; display per client locale — no server tz field).
States:
- loading:
AppSkeleton(list)rows (≤ 200 ms target); count hidden. - success+empty (
total === 0):AppEmptyStateiconreceipt_long, "No activity recorded", subtitle "Actions that happen in StudyLyon will appear here." - success+filtered-empty: "No activity matches these filters" +
TextButton"Clear filters". - error:
AppErrorState.code— 401 → session-expiry flow (00-shared/06 §3.6); 429 → countdown + retry button (api tier,rate-limit.constants.ts:6); 5xx → generic + requestId. - offline:
AppOfflineBanner+ last-good cache; Load more disabled with tooltip. - permission: route hidden without
audit.read(OQ-5); direct link → 403 screen.
Pagination (server contract — 12_API_Mapping):
- Default
limit=50(audit.controller.ts:20); client "Load more" incrementspageby 1. - Total from response
data.total(notmeta.totalItems— shape deviation, OQ-2). - Reached end (
items.length === total) → footer "End of audit trail" (bodySmall). - No infinite scroll auto-trigger on desktop (table semantics); phone may auto-load (C2 — per-design decision).
Realtime (WS, 03 §5): on WsBridge event with matching topic, cubit prepends the
entry (dedupe by _id), shows AppSnackbar "1 new entry" (tap → scroll to top + flash row
primaryContainer); if filters active and entry doesn't match → snackbar "New activity —
refresh filters". WS payloads never rendered verbatim (OQ-8).
Motion: rows m-entrance stagger 40 ms on first load; appends slide-in m-base;
filter changes cross-fade m-slow (no pop).
A11y: table exposes column headers (00-shared/09 §7); row semantics
"StudentUpdated, by A. Kumar, 10:32 — button"; live region on count + realtime banner;
focus to filter bar on /; focus ring primary.
Dark/light: token-only (surface, surfaceContainerLow cards, outlineVariant
dividers); both pass contrast.
2. Audit Filter Bar
Chrome: horizontal Wrap of controls (or bottom sheet on phone — shared 05 §5):
| Control | Component | Behaviour |
|---|---|---|
| Action | AppDropdown searchable | values = distinct action strings from loaded + cached pages (enum grows with new events — free list) |
| Entity type | AppDropdown searchable | values = distinct entityType; helper note when set: "Entity type appears only on entries that carried it (currently rare)" (OQ-1) |
| Actor | AppDropdown searchable OR AppTextField (id) | exact string match (audit.controller.ts:24); names (proposed) resolved from users module |
| Clear | TextButton | resets all → unfiltered page-1 reload |
State: active filters rendered as AppChips above results (removable individually);
any change → changeFilter → page=1 → reload. Esc clears all.
(planned): date-range presets (Today / 7d / 30d / Custom) + free-text search — need
backend from/to/q params (OQ; AUDITING.md:86).
3. Audit Entry Detail /settings/audit/:id
Chrome: AppBar "Audit entry" + menu (Copy JSON, Export (planned)); body in
SingleChildScrollView.
Header block:
[AuditActionChip: StudentUpdated] actorType chip: user
Actor: A. Kumar (u_8f2a1c…) [mono, id copyable]
Time: 02 Aug 2026, 10:32:14 (2026-08-02T05:02:14.000Z · UTC) [tooltip]
Entity: student_9f1… (when present; mono)
Trace: correlationId 9a1c… [copy icon]
Body — AuditDiffView (the diff):
- Input:
before+aftermaps (audit-log.schema.ts:45-49; masked server-side,audit.service.ts:20). - Client computes union of keys: changed (in both, different value), added (after only), removed (before only).
- Rows, one per key: key (
monolabelMedium) | badge (changedtertiary, addedsuccesssecondary, removederror) | before value (struck through,error-tint) → after value (success-tint). Scalar values inline; nested maps/arrays collapse to "Object (3 keys)" / "Array (5 items)" → tap expandsJsonTreeView(indent 16,mono). - Empty map →
AppEmptyState compact"No snapshot captured for this action." afterfallback: whenbeforeabsent andafter === payload(audit.handler.ts:42-43), header note "Snapshot after action" and render as flat JSON tree — most entries today (emitters send payload withoutbefore, e.g.users.service.ts:70-75).- No values rendered if neither: single line + note.
- All values are display-only:
SelectableText(desktop Ctrl+C); no editing affordances.
Immutability communication: footer bodySmall onSurfaceVariant: "This record is
append-only and cannot be edited or deleted." (derived from audit-log.repository.ts:6 +
PLAN 19.3, PLAN.md:191).
Not-found state (deep link, no :id endpoint, OQ-4): AppEmptyState "Entry not found —
it may be beyond the loaded pages." + "Back to list". Client retries match by loading next
pages up to a cap (proposed).
States: none loading (in-memory); motion m-base route; expand/collapse m-fast.
4. Export Sheet /settings/audit/export (planned)
AppBottomSheet (2/3 height): format radio (CSV, PDF), scope card (active filters + "all
pages"), timezone dropdown (client-side date formatting), CTA Export — disabled with
AppBanner info "Export is planned — the API does not offer it yet" (AUDITING.md:87 has no
endpoint). Enabled when backend lands; per blueprint the export itself becomes an audited
action.
5. Entry Timeline (proposed)
Not bound to a screen today; would be a tab on detail: reverse-chronology of entries sharing
entityId/correlationId. Requires backend filter support (OQ-1) — speced as
(proposed), no code.
Global micro-interaction & motion notes (all screens)
Mo-m-baseroute transitions; cardsEl-e-1; sticky table headerEl-e-2.- Snackbar: success < 3 s, error ≥ 4 s (
00-shared/03 A). - Keyboard:
/filter focus,Escclears filters/closes sheets, arrows+Enter table nav (00-shared/08 §2). - Safe areas: bottom nav-height respected in authed shell; table scrolls horizontally on
< 1200 px (min-width 720 px,
Scrollbarvisible). - Dynamic type 200%: table degrades to stacked cards automatically at any text scale (no fixed-height rows).
07 — Component Library (Audit Module)
- A. Entry representations
- B. Filtering
- C. Diff & JSON rendering (the heart of the detail view)
- D. Timeline
(proposed) - E. Status & feedback
- F. Only elements the API supports
- G. Flutter widget map (module)
- H. Golden tests (base 00-shared/03 G)
Module-specific components built from shared
App*primitives (00-shared/03). Props, states, animation, a11y, tokens, Flutter mapping. Reuse over new — only components that don't exist in the shared library are defined here. Data fields are exact fromaudit-log.schema.ts:13-56and the response contract in 12_API_Mapping.md.
A. Entry representations
AuditEntryRow (phone card)
- Purpose: one audit entry in the mobile list.
- Props:
entry(AuditEntrymodel),onTap,highlighted(realtime-append flash). - Rendering:
AppCarde-0: line 1AuditActionChip(action)+bodyMediumlocal time (DateTime.toLocal()); line 2 actor (bodyMediumname if resolved(proposed), elseactorIdmono) +entityType/entityIdmono when present; line 3correlationIdmonobodySmallonSurfaceVariant(truncated, tooltip full). - States: default / highlighted (
primaryContainerflashm-base) / pressed. - a11y: card button semantics "StudentUpdated by
at - Flutter:
Card+InkWell+Column.
AuditTable (desktop ≥ 1200 px)
- Purpose: dense read-only log table (primary desktop surface).
- Props:
rows(AuditEntry),onRowTap(id),loadingMore,total. - Columns: Time (local, UTC tooltip) · Action (
AuditActionChip) · Actor (name/id) · Entity (entityType + id mono) · Context (correlationIdmono, copy icon). - Behaviour: sticky header (
El-e-2); hover row tintm-fast; row tap → detail;Semanticscolumn headers; footer row "Showing X of Y — End of audit trail". - Flutter: custom
DataTable-free implementation —Table/TableRowinside horizontalSingleChildScrollView,ListView.builderfor rows (virtualized); orDataTable2-style if licensed — wrap inAuditTableso it is swappable.
AuditActionChip
- Purpose: compact action token with category color.
- Props:
action(string),outlined?. - Category mapping
(proposed): prefixes/contains —create|created|register→success;delete|deleted|revoke|logout→error;update|updated|change|edit|enable|disable→tertiary;login|login→primary; else neutralsurfaceVariant. Text = rawaction(server vocabulary is free-form PascalCaseeventType,audit.handler.ts:36— never translated by mapping). - a11y:
Semantics(label: action); color never sole signal — text always present. - Flutter:
AppBadge/ChipwithMaterialStatePropertybackground.
B. Filtering
AuditFilterBar
- Purpose: AND-combined filters →
GET /audit-logsparams (audit.controller.ts:22-24). - Props:
actions[],entityTypes[],actorId,onChanged(filters),onClear,busy. - Rendering:
WrapofAppDropdowns (searchable) + actorAppTextField+Clear; active filters mirrored as removableAppChips. - Behaviour: any change →
page=1reload (03 §2);Escclears. - a11y: each control labeled; results count announced (live region).
- Flutter:
Wrap+ shared dropdown widget.
C. Diff & JSON rendering (the heart of the detail view)
AuditDiffView
- Purpose: render
beforevsaftermaps (audit-log.schema.ts:45-49) as changed/added/ removed rows. - Props:
before(Map<String,dynamic>?),after(Map<String,dynamic>?),onExpand(key, value). - Computation (client-side): union of keys →
changed(value differs),added(after-only),removed(before-only). - Rendering per key: key
mono labelMedium· status badge (changed→tertiary,added→success,removed→error) · before (struck through,errortint) → after (successtint). Nested values → "Object (3 keys)" / "Array (5 items)" row → tap expandsJsonTreeView. Empty maps →AppEmptyStatecompact "No snapshot captured". - a11y: each row semantics "field
changed"; live region on expand. - Flutter:
Columnof customDiffRowwidgets;AnimatedSizeexpand.
JsonTreeView
- Purpose: inspect arbitrary payload values (
after= payload fallback,audit.handler.ts:43;metadata,audit-log.schema.ts:54-55). - Props:
value,label?,depth,copyable. - Behaviour: recursive expand/collapse;
SelectableTextleaves; "Copy JSON" action (snackbar "Copied"); collapsed arrays/objects show counts;maxDepthguard with overflow marker. - a11y: tree exposes
Semanticsexpand buttons + value labels; announce "3 keys". - Flutter: recursive
Widgetbuilder;ExpansionTile-free custom (dense).
D. Timeline (proposed)
EntryTimeline
- Purpose: reverse-chronology of related entries (same
entityId/correlationId). - Props:
entries[],onEntryTap. - Rendering: vertical rail + dots per entry (time,
AuditActionChip, actor). - Status:
(proposed)— needs backendentityIdfilter (OQ-1) or client grouping. - Flutter: custom
CustomPaintrail + list.
E. Status & feedback
- Reuses
AppSkeleton,AppEmptyState,AppErrorState,AppOfflineBanner,AppSnackbarfrom 00-shared/03 unchanged. AuditNewEntryBanner: inlineAppBanner info"1 new entry — tap to view" (realtime append), auto-dismiss 6 s; a11y live region.
F. Only elements the API supports
- No create/edit/delete/export actions — server exposes a single GET (
audit.controller.ts:17); anything else is(planned)(AUDITING.md:87) and speced disabled. - No actor-name field in the response — name resolution
(proposed)via users module.
G. Flutter widget map (module)
| Component | Flutter widgets |
|---|---|
| AuditEntryRow | Card + ListTile-style Row |
| AuditTable | Table/custom rows in SingleChildScrollView |
| AuditActionChip | Chip + Badge |
| AuditFilterBar | Wrap + shared AppDropdown |
| AuditDiffView | custom DiffRow column |
| JsonTreeView | recursive ExpansionTile-like builder |
EntryTimeline (proposed) | CustomPaint rail |
H. Golden tests (base 00-shared/03 G)
Every module component: golden at phone/tablet/desktop + dark mode; AuditDiffView with
changed/added/removed/nested/empty permutations; JsonTreeView deep + long-string cases.
08 — Form Specifications (Audit Module)
Every form field field-by-field. The Audit module has one form: the filter bar. There are no mutation forms — the module is append-only read (
audit.controller.ts:17), so no create/update/delete DTOs exist. Validation mirrors the absence of server-side DTOs:AuditController.query()takes raw query params with defaults (audit.controller.ts:19-25) — there is noAuditQueryDto, hence no class-validator rules; the client self-validates.
1. Audit Filter Form (non-mandatory, all fields optional)
| # | Field | Control | Query param | Server handling | Client UX / validation |
|---|---|---|---|---|---|
| 1 | Action | AppDropdown (searchable, free list) | action | optional string → filter.action (audit.controller.ts:22,27) | values = distinct actions from data; free text allowed (enum is open — audit.handler.ts:36) |
| 2 | Entity type | AppDropdown (searchable) | entityType | optional string → filter.entityType (audit.controller.ts:23,28) | exact match against entityType; helper note: rarely populated by emitters (OQ-1) |
| 3 | Actor | AppDropdown/AppTextField | actorId | optional string → filter.actorId (audit.controller.ts:24,29) | exact actorId string; name→id resolution (proposed) via users module |
| 4 | Page | implicit | page | default 1, cast Number(page) (audit.controller.ts:20,31) | client keeps internally; non-numeric → server CastError → 400 (http-exception.filter.ts:47-55) |
| 5 | Limit | implicit | limit | default 50, cast Number(limit) (audit.controller.ts:20,32) | client uses 50; unbounded server-side (no max clamp, OQ-7) |
| 6 | Date range | (planned) — no from/to params exist | — | — | blueprint promises date-range querying (AUDITING.md:86) — backend (planned) |
| 7 | Search q | (planned) | — | — | shared convention q (00-shared/07 §5) not supported by this controller |
| 8 | Sort | none (fixed) | — | — | always occurredAt desc (audit-log.repository.ts:23); sort param unsupported |
Filter combination rule (server): all present filters AND-combined into one Mongo
filter object, always merged with the tenant scope:
{ tenantId, action?, entityType?, actorId? } (audit.controller.ts:26-29,
audit.service.ts:45-48).
Form-level rules:
- Submitting a filter → reset
page=1→ reload; no debounce needed (explicit dropdown selection, not keystrokes); actor text field debounce 300 ms (AppSearchBardefault). - "Clear filters" resets all three fields + page.
- Double-submit: dropdowns single-select, no pending overlap; Load-more disabled while a request is in flight.
- Optimistic: none — filter changes always hit the server (list is server-paginated).
- Error priority (client, 00-shared/06 §5): 400/404 → keep filters, snackbar; 429 →
countdown; 5xx →
AppErrorState; 401 → session flow.
Empty-result copy: "No activity matches these filters" + Clear filters (not an error —
total === 0 is a valid success state).
09 — User Behaviour (Audit Module)
- 1. Expected behaviour (server-contract-driven)
- 2. Common mistakes & countermeasures
- 3. Power & new users
- 4. Mobile & a11y users
- 5. Interrupted sessions & retries
- 6. Abandonment
- 7. Frustration points → UX treatments (summary table)
- 8. Adoption path (holistic)
Expected behaviour (derived from server semantics + shared interaction rules), common mistakes users make with these screens, and the UX countermeasures.
1. Expected behaviour (server-contract-driven)
- Everything they see is append-only: no edit/delete/undo affordances anywhere; footer on
detail states it (
audit-log.repository.ts:6, PLAN 19.3PLAN.md:191). - Timestamps are authoritative: display
occurredAtin local time; hover/tooltip shows UTC ISO (audit-log.schema.ts:42-43); there is no server timezone field — the client owns tz display. - The list is server-paginated: page 1 loads 50 (
audit.controller.ts:20); scrolling loads more; pull-to-refresh resets to page 1. - New activity appears live: while the list is open, WS appends arrive without manual
refresh (
ws-bridge.service.ts:16-22). - Filters are exact-match on raw strings (
action/entityType/actorId—audit.controller.ts:22-24), not fuzzy search; the client explains that. - Masking is already applied server-side: sensitive payload keys show
***— the user will see masked values inbefore/after(audit.service.ts:20); UI must not hint that secrets are retrievable.
2. Common mistakes & countermeasures
| Mistake | Related | UX countermeasure |
|---|---|---|
| Expecting a search box that finds any text | no q param (08 §1 row 7) | search control hidden/disabled with "search (planned)" tooltip; interim client-side page filter |
| Expecting date-range filtering ("what happened last Tuesday?") | no from/to (AUDITING.md:86 vs code) | date controls (planned); until then: paginate back, sort is fixed desc (audit-log.repository.ts:23) |
| Filtering by entity type and getting nothing | emitters rarely set entityType (OQ-1) | inline helper note on the dropdown: "Entity type appears only on entries that carried it" |
| Thinking the diff is a full record | before mostly absent; after = payload (audit.handler.ts:42-43) | "Snapshot after action" label; empty-diff empty state |
| Trying to "correct" a wrong entry (impossible) | append-only | no edit paths exist; detail footer explains immutability; support workflow = new action |
| Misreading timestamps across timezones | UTC storage, local display | UTC in tooltip; column header note on desktop |
| Clicking rows expecting a server refetch | no :id endpoint (OQ-4) | instant in-memory detail (fast); deep-link fallback message |
3. Power & new users
- Power user (compliance officer, desktop): keyboard-first table —
/filter, arrow navigation,Enteropen,Ctrl+Ccopy JSON on detail; "Load more" until the window is covered; export(planned). - New user: list + filter bar are self-explanatory; empty state teaches ("Actions that happen in StudyLyon will appear here.").
4. Mobile & a11y users
- One-thumb phone: compact
AuditEntryRowcards; filter bar collapses to aAppBottomSheet("Filter" button) to keep the list scannable. - TalkBack/VoiceOver: table headers announced; diff rows labeled "field X changed"; realtime banner announced via live region.
- Dynamic type 200%: table degrades to stacked rows (06 §2); no clipping.
- Reduced motion: appends/rows fade only (
Mo-m-instant); no stagger.
5. Interrupted sessions & retries
- Suspend/resume: list re-fetches on resume if stale > 5 min (volatile list TTL, 00-shared/06 §3.3); WS resubscribes on reconnect.
- Mid-load interruption: skeleton stays; error state offers retry.
- Retry after 429: countdown; no auto-retry (00-shared/07 §4).
- Server 5xx: generic error +
requestId; retry keeps current filters.
6. Abandonment
- Where people quit: filter sheet dismissal (keeps filters), deep-link entry not found
(back to list), export sheet
(planned)disabled (acknowledged note). - Filters + scroll persist per session in the cubit (StatefulShellBranch keeps tab state, 00-shared/05 §3); not persisted across app restarts (no requirement).
7. Frustration points → UX treatments (summary table)
| Frustration | Treatment |
|---|---|
| "Where is last week's entry?" | date-range filter (planned); until then "load more" + fixed desc sort |
| "Why is this filter empty?" | helper notes on entityType/actor dropdowns |
| "What changed exactly?" | AuditDiffView changed/added/removed; JSON tree for payloads |
"Who is actor u_8f2a…?" | name resolution (proposed) via users module; always show id anyway (mono, copyable) |
| "Can I get a CSV?" | export (planned) — disabled control with honest note |
8. Adoption path (holistic)
P0: list + filters + detail (in-memory) + realtime append. P1: actor-name resolution,
keyboard-first table refinements. P2 (planned): date-range + search (backend), export,
entry timeline, RBAC preset wiring into access-audit.
10 — Interaction Specification (Audit Module)
- 1. Phone / touch
- 2. Keyboard / desktop / mouse
- 3. Haptics (mobile)
- 4. Motion language (module extension of 08 §4)
- 5. Micro-interactions catalogue
- 6. Frustration controls (shared 08 §6 + module)
- 7. Special: realtime & clipboard interactions
- 8. Validation of interaction to server semantics
Module-specific interactions layered on shared 00-shared/08. Covers every input modality on phone/tablet/desktop for a read-only, high-density log surface.
1. Phone / touch
| Gesture | Behaviour | Where |
|---|---|---|
| Tap | open detail, open filter sheet, clear filters, copy id/json, expand JSON node | rows, filter bar, detail |
| Long-press | AppMenu (View entry, Copy ID, Export (planned)) | list rows |
| Swipe left | none destructive (read-only; no delete exists) — no swipe actions | — |
| Pull down | refresh → reset page 1 (RefreshIndicator, bypasses cache) | list |
| Pinch/drag | none (no media) | — |
| Hardware back | detail → list (filters/scroll kept); list → Settings | all |
2. Keyboard / desktop / mouse
| Input | Behaviour |
|---|---|
/ | focus filter bar (global pattern: Ctrl+K is shell search; / is in-screen filter) |
| Tab | filter controls → table → footer; visible primary focus ring (00-shared/09 §6) |
| Arrow up/down | move table row selection |
| Enter | open selected entry detail |
Esc | clear filters / close filter sheet / collapse JSON |
| Ctrl/Cmd+C | copy JSON on detail (with SelectableText) |
| Ctrl/Cmd+F | focus filter bar (alias of /); browser-find disabled on web via semantics where possible |
| Right-click | context menu (same as long-press) on rows |
| Hover | row tint m-fast; tooltip 500 ms (UTC timestamp, truncated values, full action) |
3. Haptics (mobile)
| Event | Haptic | Ref |
|---|---|---|
| Realtime entry appended | selectionClick (subtle; not disruptive) | — |
| Copy ID/JSON | selectionClick | — |
| Filter applied | none | — |
| Rule | disabled when system haptics off; none on desktop | 08 §3 |
4. Motion language (module extension of 08 §4)
| Transition | Duration/curve | Used by |
|---|---|---|
| Route push (list→detail) | m-base slide (phone); master-detail pane swap m-fast (≥ 840 px) | detail |
| Realtime append | slide-in m-base + primaryContainer flash | list |
| First-load rows | m-entrance stagger 40 ms | list |
| Filter change | cross-fade m-slow (content swap, no pop) | list |
| JSON expand/collapse | AnimatedSize m-fast | detail |
| Table hover | m-fast tint | desktop table |
Reduced-motion: fades only, no stagger, no flash.
5. Micro-interactions catalogue
| Interaction | Detail |
|---|---|
| Row tap (table) | ripple m-fast; detail pane opens m-fast (master-detail) |
| Load more | bottom spinner fade-in m-instant; button label "Loading…" |
| Realtime banner | slide-in AppBanner info; auto-dismiss 6 s; tap → scroll to top + flash row |
| Copy ID/JSON | Snackbar "Copied" + lightImpact-less (selectionClick) |
| Clear filters | chips remove one-by-one m-fast; "Clear" resets all |
| Diff expand | AnimatedSize; badge colors never sole signal (text + icon) |
6. Frustration controls (shared 08 §6 + module)
- All presses respond < 100 ms (
m-fastripple). - Every network action shows pending state (skeleton, load-more spinner) — never silent.
- Load-more disabled while in flight (anti-duplicate pages).
- 429 → countdown, no auto-retry.
- Errors persist until dismissed/retried (never flash).
7. Special: realtime & clipboard interactions
- WS lifecycle: on connect, join
tenant:{tenantId}(automatic,ws.gateway.ts:50); on reconnect → resubscribe + background refetch (page 1 merge). - Clipboard: detail JSON copy (entire entry), correlationId copy, entry
_idcopy. - Deep link to entry:
studylyon://settings/audit/:id→ in-memory lookup; miss → load next pages (cap(proposed), OQ-4) → miss → empty state.
8. Validation of interaction to server semantics
- No gesture implies edit/delete (server has no such paths —
audit-log.repository.ts:6). - Filters map 1:1 to query params (
audit.controller.ts:22-24); client never sends params the controller ignores (sort,q, dates are(planned)). - Realtime append derives from the same event stream as persisted entries
(
ws-bridge.service.ts:16-22vsaudit.handler.ts:18-25) — dedupe by_id. - Timestamps always from
occurredAt(schematimestamps: false—createdAtnever used for display,audit-log.schema.ts:13, 42-43).
11 — Design System Mapping (Audit Module)
- 1. Theme recipes (module)
- 2. Widget → token
- 3. Typography mapping
- 4. Iconography
- 5. Motion tokens (values 00-shared/02 §6)
- 6. Contrast & dark parity
- 7. Breakpoint behaviour
- 8. Spacing & density
Audit module → Material 3 / Flutter / token mapping, extending 00-shared/04. Every color, type, spacing, elevation, radius, icon, motion reference traces to 00-shared/02_Design_Tokens.md. Diff semantics use the standalone custom roles
success/warning/error(00-shared/02 §1.1).
1. Theme recipes (module)
| Surface | Theme | Exception |
|---|---|---|
| List (cards/table) | surface, rows surfaceContainerLow | sticky table header surfaceContainerHigh El-e-2 |
| Realtime-new row flash | primaryContainer transient | back to surface after m-base |
| Diff added | secondary (success) tinted row | text on onSecondary-adjacent surface |
| Diff removed | error tinted row, struck-through | never color-only (icon + label) |
| Diff changed | tertiary (warning) tinted row | — |
| Immutability footnote | bodySmall onSurfaceVariant | — |
2. Widget → token
| Component | M3 / Flutter | Tokens | Elev | Radius |
|---|---|---|---|---|
| List card (phone row) | AppCard flat | surfaceContainerLow | e-0 | r-lg |
| Desktop table | custom Table | surface; header surfaceContainerHigh; divider outlineVariant | e-0/e-2 header | — |
| Filter bar | Wrap + dropdowns | fill surfaceVariant, border outline, focus primary | — | r-md |
| Active filter chips | AppChips selected | selected secondaryContainer | — | r-sm |
AuditActionChip | Chip/Badge | category: success/error/tertiary/primaryContainer | — | r-sm |
| Diff row — changed | row + Badge tertiary | tertiary icon change | — | r-sm |
| Diff row — added | row + Badge secondary | secondary icon add | — | r-sm |
| Diff row — removed | row + Badge error | error icon remove + TextDecoration.lineThrough | — | r-sm |
| JSON tree | nested SelectableText | mono; depth indent 16 | — | — |
| Empty state | AppEmptyState | displaySmall title, bodyMedium onSurfaceVariant | — | — |
| Realtime banner | AppBanner info | primaryContainer | e-1 | r-md |
| Snackbars | SnackBar | inverseSurface | e-5 | r-md |
3. Typography mapping
| Token | Screen element |
|---|---|
headlineMedium | list/detail screen titles |
titleMedium | row action line, section headers |
bodyMedium | actor/time lines, filter labels |
bodySmall | context line (correlationId), footnotes, immutability note |
labelMedium | diff key labels |
mono | actorId, entityId, correlationId, JSON values, table Context column |
Numerals (times, counts) use FontFeature.tabularFigures() (00-shared/02 §2).
4. Iconography
Material Symbols outlined; module map: receipt_long (log), manage_search (filter),
history (timeline (proposed)), person/badge (actor), change_circle (diff changed),
add_circle (diff added), remove_circle (diff removed), file_download (export
(planned)), content_copy (copy), filter_alt (filter sheet), bolt/new_releases
(realtime new), keyboard_arrow_right (row chevron).
5. Motion tokens (values 00-shared/02 §6)
| Purpose | Token | Applied to |
|---|---|---|
| Route push detail | m-base | list→detail |
| Realtime append | m-base + flash | new row |
| First-load stagger | m-entrance (40 ms) | row entrance |
| Filter content swap | m-slow cross-fade | result area |
| JSON expand | m-fast AnimatedSize | detail |
| Hover tint | m-fast | table rows |
Rules: reduced-motion substitutes fades/instants (08 §4); no parallax/stagger.
6. Contrast & dark parity
- All text ≥ 4.5:1; UI chrome ≥ 3:1 (00-shared/09 §2).
- Diff colors on dark: chromatic dark variants TBD by designer (00-shared/02 §1.4); all text on row surfaces keeps ≥ 4.5:1.
- Never color-only status: every diff badge pairs icon + text (00-shared/09 §9).
- Dark: sticky header
surfaceContainerHigh; JSONmonoonsurfaceContainerLow.
7. Breakpoint behaviour
| Class | Layout |
|---|---|
| phone < 600 | filter bar in bottom sheet; AuditEntryRow cards; detail pushed |
| 600–839 | filter bar inline; cards 2-column (tablet) |
| 840–1199 | master-detail: list left, detail right pane; table (min-width 720 scrolls) |
| ≥ 1200 | full-width AuditTable (primary target); master-detail; keyboard nav; breadcrumbs |
8. Spacing & density
- Table row height 56 (default) / 40 (dense toggle
(proposed)for compliance scanning). - Filter bar gap 12; section gap 24; card padding 16 (00-shared/02 §3).
- Touch targets ≥ 48; dense inline controls 44 with tooltips.
12 — API Mapping (Audit Module)
- 0. Module-wide request envelope & client policy
- Non-existent surfaces (append-only — do not build against them)
- Loading / streaming / realtime
- Client-side error mapping table (module)
- Optimistic / undo
Exact wire contract for the Audit screens → the single read endpoint. Base
/api/v1; envelope per 00-shared/07. All endpoints fromsrc/modules/audit/audit.controller.ts; business rules fromaudit.service.ts+audit.handler.ts+audit-log.repository.ts. Global guards:RateLimitGuard→JwtAuthGuard→RbacGuard(app.module.ts:129-133). Append-only module: no create/update/delete/export endpoints exist.
0. Module-wide request envelope & client policy
| Aspect | Contract |
|---|---|
| Base | https://api.<domain>/api/v1 |
| Headers | Authorization: Bearer <accessToken>; x-request-id client-generated |
| Success | {success:true, message:"OK", data, meta?, timestamp, requestId} (response-envelope.interceptor.ts:11-18) |
| Error | {success:false, message, error:{code,details?}, timestamp, requestId} (http-exception.filter.ts:16-25) |
| Tenancy | tenantId from JWT claim → TenantContextService.requireTenantId() (audit.controller.ts:30, jwt-auth.guard.ts:44-55); never in query/body |
| Rate limit | default api tier 100/min (rate-limit.constants.ts:6) — no @RateLimit override on the controller |
| Permission | server: JwtAuthGuard only (audit.controller.ts:9); audit.read (permissions.constants.ts:54) unenforced → Phase-5 (planned) (docs/IMPLEMENTATION_PLAN.md:241) |
| Cache | server none; client last-good cache 5 min (volatile list, 00-shared/06 §3.3) |
| Offline | reads from cache + AppOfflineBanner; writes n/a (no writes exist) |
Screen: Audit log list (and filter bar)
| Endpoint | GET /audit-logs (audit.controller.ts:17) |
| Query params | page (default 1), limit (default 50), action?, entityType?, actorId? (audit.controller.ts:20-24) |
| Filters | AND-combined: {action, entityType, actorId} → Mongo filter + tenant scope (audit.controller.ts:26-29, audit.service.ts:45-48) |
| Sort | fixed occurredAt desc (audit-log.repository.ts:23); no sort/q support |
| Auth | Bearer JWT (valid = allowed today; audit.read recommended gate client-side, OQ-5) |
| Errors | 400 VALIDATION_ERROR (non-numeric page/limit → CastError, http-exception.filter.ts:47-55); 401 UNAUTHENTICATED; 429 RATE_LIMITED; 5xx INTERNAL_SERVER_ERROR |
Actual response (exact — derived from code):
{
"success": true,
"message": "OK",
"data": {
"data": [
{
"_id": "66f0…",
"tenantId": "t_springfield",
"actorId": "u_8f2a…",
"actorType": "user",
"action": "StudentUpdated",
"entityType": null,
"entityId": null,
"occurredAt": "2026-08-02T05:02:14.000Z",
"after": { "studentId": "s_9f1…", "changes": ["guardianPhone"] },
"correlationId": "9a1c…",
"before": null,
"metadata": null,
"isDeleted": false,
"version": 0
}
],
"total": 1234
},
"timestamp": "2026-08-02T05:02:15.000Z",
"requestId": "req_…"
}
Pagination shape deviation (critical, OQ-2):
AuditService.query()returns{data, total}withoutmeta(audit.service.ts:41-50). The envelope interceptor treats a payload as paginated only when it has bothdataandmeta(response-envelope.interceptor.ts:25-32), so this response is wrapped as a plain object: client readsenvelope.data.data[]andenvelope.data.total. It does not receivemeta:{page,limit,totalItems,totalPages,hasNext,hasPrevious}per the shared paginated contract (00-shared/07 §2). Client computes paging fromtotal+ requestedpage/limit.
Entry document fields (exact, audit-log.schema.ts:13-56 + inherited base.schema.ts:8-34):
| Field | Type | Notes |
|---|---|---|
_id | string | row key, dedupe key |
tenantId | string | required, index (base.schema.ts:10-11) |
actorId | string | required (audit-log.schema.ts:15-16) |
actorType | user|system|api_key | default user; handler always writes user (audit-log.schema.ts:18-19, audit.handler.ts:35) |
action | string | required = eventType (PascalCase, e.g. UserCreated) (audit-log.schema.ts:21-22, audit.handler.ts:36) |
entityType / entityId | string? | rarely populated today (OQ-1) (audit-log.schema.ts:24-28, audit.handler.ts:37-40) |
ipAddress / device / browser / userAgent | string? | schema fields, never written by current handler (OQ) (audit-log.schema.ts:30-40) |
occurredAt | ISO-8601 UTC | required; the display timestamp (audit-log.schema.ts:42-43); schema timestamps:false → no createdAt |
before / after | object? | masked at write (audit.service.ts:20); before set only when emitter supplies it; after falls back to whole payload (audit.handler.ts:42-43) |
correlationId | string? | trace link (audit-log.schema.ts:51-52) |
metadata | object? | payload.metadata passthrough (audit-log.schema.ts:54-55, audit.handler.ts:44) |
isDeleted/version/createdBy/… | inherited | always false/0/absent — never displayed (base.schema.ts:13-34) |
Client flows: load(page, filters) → map → model; loadMore() → page+1 while
items.length < total; pullToRefresh() → page=1 bypassing cache; changeFilter()
→ page=1.
Endpoint source: audit.controller.ts:17-34, audit.service.ts:37-51, audit-log.repository.ts:19-28
Non-existent surfaces (append-only — do not build against them)
| Surface | Status | Evidence |
|---|---|---|
GET /audit-logs/:id (entry detail) | absent — detail renders in-memory (OQ-4) | controller has one route (audit.controller.ts:17) |
POST/PATCH/DELETE /audit-logs* | absent — append-only | audit-log.repository.ts:6, 15-28; PLAN 19.3 (PLAN.md:191) |
| Export CSV/PDF | (planned) — blueprint only | AUDITING.md:87; no endpoint |
Date-range / q / sort filters | (planned) | controller params only page/limit/action/entityType/actorId (audit.controller.ts:19-25) |
Platform (tenantId:null) entries | (planned) — not implemented | AUDITING.md:70-71 vs audit-log.schema.ts:7-11 |
Loading / streaming / realtime
| Screen | Loading | Streaming | Realtime |
|---|---|---|---|
| audit list | AppSkeleton rows | Load-more pages | WS append (ws-bridge.service.ts:16-22) — topics = eventType strings, joined room tenant:{tenantId} (ws.gateway.ts:50) |
| entry detail | n/a (in-memory) | — | — |
| export | (planned) | (planned) streamed per blueprint | — |
Client-side error mapping table (module)
| Screen | code | UI |
|---|---|---|
| list/detail | 401 | silent refresh → fail → session-expiry (00-shared/06 §3.6) |
| list | 429 | countdown chip; no auto-retry |
| list | 400 (bad page/limit) | reset to page 1, snackbar |
| list | 5xx | AppErrorState generic + requestId |
| list | 403 (future Phase-5) | 403 screen; route hidden pre-emptively |
Optimistic / undo
None — read-only module; no mutations, no optimistic updates, no undo (09 §1).
13 — State Management (Audit Module)
- 1. AuditCubit — list + filters + pagination (single owner of list state)
- 2. AuditDetailState (in-memory; no refetch — no
:idendpoint, OQ-4) - 3. WS subscription (realtime append)
- 4. Caching & staleness
- 5. Cross-cutting interplay
- 6. Error states per action
- 7. Testing hooks (
00-shared/06 §6)
Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) for the read-only audit trail. Backed by
AuditRepository(dio) callingGET /audit-logs(12_API_Mapping.md) and the WS topic stream (ws-bridge.service.ts:16-22).
1. AuditCubit — list + filters + pagination (single owner of list state)
stateDiagram-v2
[*] --> initial
initial --> loading : load()
loading --> loaded(items, total) : page 1 ok
loading --> error(code) : 4xx/5xx/offline
loaded --> loading : changeFilter() | pullToRefresh() (page=1)
loaded --> loadingMore : loadMore() (page+1)
loadingMore --> loaded : appended items
loadingMore --> error : failure (keep items, snackbar)
loaded --> loaded : realtimeAppend(entry) — prepend, dedupe by _id
error --> loading : retry()
State:
class AuditState {
final AuditFilter filters; // action?, entityType?, actorId?
final List<AuditEntry> items; // accumulated pages
final int total; // envelope.data.total (audit.service.ts:49)
final int page; // last loaded page
final bool isLoadingMore;
final LoadState loadState; // initial|loading|success|error (00-shared/06 §3.1)
final int? realtimeCount; // unacknowledged WS appends
}
Events → methods:
| UI event | Cubit | Repository call |
|---|---|---|
| open screen | load() | GET /audit-logs?page=1&limit=50 (audit.controller.ts:20-21) |
| Load more | loadMore() | GET /audit-logs?page={page+1}&limit=50 |
| Pull-to-refresh | pullToRefresh() | GET ?page=1 bypass cache |
| Filter change / clear | changeFilter(f) | GET ?page=1&action=&entityType=&actorId= (only non-empty params, audit.controller.ts:26-29) |
| Retry | retry() | re-run last request |
| WS event | onWsEvent(event) | none — derive AuditEntry from broadcast (eventType, occurredAt, payload); prepend only if matching active filters, dedupe by _id; increment realtimeCount → snackbar/banner |
Pagination math: hasMore = items.length < total (server total, no meta.hasNext —
OQ-2); page advances by 1 per load; guard: no loadMore while isLoadingMore or during
loading.
2. AuditDetailState (in-memory; no refetch — no :id endpoint, OQ-4)
class AuditDetailState {
final AuditEntry? entry; // passed from list / deep-link lookup
final bool notFound; // deep-link miss after lookup cap
}
- No loading/network states — the entry object is already in the list payload
(
12_API_Mapping). If deep link misses, the cubit iterates subsequent pages (cap(proposed)) then setsnotFound. - Diff computation is a pure function
computeDiff(before, after) → (changed, added, removed)(07 §C) — unit-testable, no state.
3. WS subscription (realtime append)
- Client connects via
WsClient(00-shared/11 §5), bearer token in handshake (ws.gateway.ts:37-40); server auto-joinstenant:{tenantId}(ws.gateway.ts:50). - AuditCubit listens to all
eventTypetopics of the domain-event stream — the same events the handler persists (ws-bridge.service.ts:16-22vsaudit.handler.ts:18-25), guaranteeing append parity. - On WS reconnect:
pullToRefresh()(merge + dedupe). - Safety: broadcast payloads are unmasked (OQ-8) — the cubit never stores raw broadcast payload fields into a displayed entry; on tap, the row expands from the cached (masked) REST object when available, else shows generic summary.
4. Caching & staleness
- List: last-good cache key
sl:cache:{tenant}:audit:{filters-sha}; TTL 5 min (volatile list,00-shared/06 §3.3); stale-while-revalidate;RefreshIndicatorbypasses cache. - Detail: no cache (in-memory only).
- Filter dropdown option lists (distinct actions/entityTypes): derived from accumulated pages + cached; refreshed on page 1 loads.
5. Cross-cutting interplay
AuthCubit: 401 → single-flight refresh → fail →sessionExpired(list reopens after re-login).ConnectivityCubit: offline →AppOfflineBanner+ cached list; WS buffered; on reconnect → resubscribe + refetch.FeatureFlagsCubit: none (audit list is not feature-gated).- RBAC module:
/settings/access-audituses the sameAuditCubitwith a preset filter (actionin RBAC set) — shared widget + state, different entry route (design-docs/rbac/05 S6).
6. Error states per action
| Action | Error | State → |
|---|---|---|
| load/loadMore | 401 | session-expiry flow |
| load/loadMore | 429 | RATE_LIMITED → countdown, keep items |
| loadMore | 5xx | snackbar + keep isLoadingMore=false (retryable) |
| load | 5xx | error state → AppErrorState |
| load | 400 (bad page/limit) | reset page=1 |
7. Testing hooks (00-shared/06 §6)
- Unit: pagination math (hasMore/total edge), filter→query mapping, diff computation, WS append dedupe + filter-matching, realtime-count acknowledgement.
- Widget: loading/error/empty/loaded states; banner; table vs card layout switch.
14 — QA Checklist (Audit Module)
- 1. Functional
- 2. Visual / layout
- 3. Accessibility (00-shared/09)
- 4. Performance
- 5. Offline & network (10 §2)
- 6. Tablet / desktop
- 7. Localization
- 8. Permissions
- 9. Dark mode
- 10. Animations
- 11. Security
- 12. Server-backed edge mirrors
- QA scripts
Module QA specific to framework + shared 00-shared/10. Every check derives from a specific backend behavior so failure analysis is direct.
1. Functional
-
List loads
GET /audit-logswith defaultspage=1&limit=50(audit.controller.ts:20-21). -
Response parsed per actual shape
envelope.data.data[]+envelope.data.total(nometa— OQ-2;response-envelope.interceptor.ts:25-32). -
Entries sorted
occurredAtdesc (never asc/unsorted) (audit-log.repository.ts:23). -
Filters
action/entityType/actorIdsent as query params only when set; AND combination works (audit.controller.ts:26-29). -
Filter change resets to page 1; Load more appends;
hasMore = items.length < total. -
entityTypefilter: when data has no such entries, empty state shows helper note (OQ-1), never a raw error. -
Entry detail renders from in-memory entry (no extra request — no
:idendpoint, OQ-4). -
Diff view: changed/added/removed rows computed correctly;
beforeabsent → "Snapshot after action" label (audit.handler.ts:42-43); both absent → empty state. -
Immutability checks: sending
PATCH/DELETEto/api/v1/audit-logs*returns 404 (no routes —audit.controller.ts:17); no edit/delete UI exists; PLAN 19.3 (PLAN.md:191). -
Masking: response
afterfor aUserCreated-style event never containspasswordHash/password/totpSecret/refreshToken/accessToken/token— masked'***'(audit.service.ts:5-12, 20). -
Realtime: while list open, another session performs an action → entry appears
(≤ ~1 s), dedupe by
_id; banner count correct; WS payloads never rendered verbatim (OQ-8). - WS reconnect → resubscribe + silent refetch (merge without duplicates).
-
Non-numeric
page/limithandled: client never sends them; if server 400 arrives → reset page 1 (http-exception.filter.ts:47-55). -
correlationIdpresent on detail and copyable (traceability,audit-log.schema.ts:51-52).
2. Visual / layout
- Desktop table ≥ 1200 px: sticky header, hover, arrow-key nav, horizontal scroll ≤ 1199 px (min-width 720).
- Phone cards + filter bottom sheet; tablet master-detail ≥ 840 px.
-
Diff colors (added
secondary/ removederror/ changedtertiary) pass contrast in light and dark; never color-only (icon + text) (00-shared/09 §9). -
Long
correlationId/entityIdmono strings truncate with tooltip, wrap at 200% text scale (no clipping). -
Dark parity: sticky header
surfaceContainerHigh, JSON onsurfaceContainerLow.
3. Accessibility (00-shared/09)
- TalkBack: full list→filter→detail flow operable without sight; table column headers announced; diff rows labeled "field X changed".
-
VoiceOver separately; keyboard-only desktop (
/focus, arrows, Enter, Esc ). - Realtime append + result count announced via live regions.
- Reduced motion: no stagger/flash (appends fade only).
- Contrast body 4.5:1, icons 3:1.
4. Performance
- List first frame ≤ 300 ms cached / ≤ 2 s network (00-shared/10 §1); skeleton ≤ 200 ms.
-
ListView.builder/virtualized table — no unbounded widget build on 10 000+ rows. - Deep pages (page 50+) load without jank; no duplicate parallel requests for same page (load-more guard).
- JSON tree: long arrays/objects collapsed by default; expand is lazy.
5. Offline & network (10 §2)
- Offline → cached list + banner; Load more disabled; filter changes blocked (no stale queries) with guidance.
- Reconnect → WS resubscribe + refetch, merged without duplicates.
- Slow network × Load more: bottom spinner, no ANR; retry after failure keeps list.
6. Tablet / desktop
- Master-detail at ≥ 840 px (list selection persists in detail pane).
- Right-click context menu = long-press menu; hover tooltips (UTC time, truncated values, full action).
- Enter submits filter; Tab order filter → table → footer; focus ring visible.
7. Localization
-
All strings via i18n keys; date/time via
Intllocale; UTC in tooltip; no hardcoded date formats. - RTL readiness: table/diff/key order logical; no hardcoded alignment.
-
Long localized action strings (never translated — raw
eventType) don't break row height (ellipsis + tooltip).
8. Permissions
-
Client hides route without
audit.read(permissions.constants.ts:54); direct link → 403 screen. -
Known server gap (OQ-5): today any authenticated user can call
GET /audit-logs(JWT-only guard,audit.controller.ts:9) — verify with security owner whether student/parent tokens must be blocked server-side before Phase-5 (docs/IMPLEMENTATION_PLAN.md:241). -
Cross-tenant: no UI path can read another tenant's entries (server scopes by
requireTenantId(),audit.controller.ts:30).
9. Dark mode
- Full visual pass on list/table/detail/diff/JSON; token-only colors.
10. Animations
-
m-*tokens only; reduced-motion respected; no flash on realtime append.
11. Security
-
Masked
'***'values displayed as-is — UI never hints secrets are recoverable. - WS broadcast payloads not logged/cached in display state (OQ-8).
- Copy JSON on detail never includes raw secrets (server-masked only).
-
Retention conflict flagged (OQ-6):
migrate.ts:23TTL 90 days onaudit_logs.occurredAtvs 7-year policy (DATA_RETENTION.md:29) — product/security decision required; QA should re-verify retention after fix.
12. Server-backed edge mirrors
-
totalshown matches count when filters applied (servercountcall,audit.service.ts:48). -
Entry with
metadatarenders nested JSON tree (audit-log.schema.ts:54-55). -
actorTypeother thanuser(futuresystem/api_keywrites) renders badge correctly (audit-log.schema.ts:18-19).
QA scripts
- Golden:
AuditTable,AuditEntryRow,AuditDiffView(changed/added/removed/empty/ nested),JsonTreeView, dark mode, 3 sizes. - E2E (P0): login as org_admin → open audit → filter by action → open entry → verify diff → realtime append from second session → immutability probe (PATCH/DELETE → 404).
- Security smoke: student-role token calls
GET /audit-logs→ record current behavior (allowed today, OQ-5) and confirm Phase-5 enforcement when landed.
15 — Flutter Implementation Guide (Audit Module)
- 1. Folder structure
- 2. Dependencies
- 3. AuditRepository (read-only)
- 4. Diff rendering (core algorithm)
- 5. Table layout on desktop (≥ 1200 px)
- 6. Timeline UI
(proposed) - 7. Navigation
- 8. Localization keys
- 9. Theme
- 10. Testing
- 11. Performance
- 12. Proposals flagged to the team
How to build the Audit feature in the Flutter client on top of 00-shared/11. Forward-looking spec; no client repo exists yet. Read-only feature: no write repositories, no optimistic updates, no forms beyond filters.
1. Folder structure
features/audit/
├── domain/
│ ├── models/
│ │ ├── audit_entry.dart # schema mirror (12_API_Mapping): id, tenantId,
│ │ │ # actorId, actorType, action, entityType?, entityId?,
│ │ │ # occurredAt(DateTime), before?, after?, correlationId?,
│ │ │ # metadata?, ipAddress?, device?, browser?, userAgent?
│ │ └── audit_filter.dart # action?, entityType?, actorId? (query params)
│ ├── diff/
│ │ └── field_diff.dart # FieldDiff {key, status: changed|added|removed, before?, after?}
│ └── exceptions/audit_exceptions.dart
├── data/
│ ├── dto/audit_entry_dto.dart # envelope.data.data[] + envelope.data.total mapper (OQ-2)
│ └── repositories/audit_repository.dart
└── presentation/
├── cubit/
│ ├── audit_cubit.dart # list + filters + pagination + WS append (13 §1)
│ └── audit_detail_cubit.dart # in-memory detail + deep-link lookup
├── pages/
│ ├── audit_list_page.dart
│ ├── audit_detail_page.dart
│ └── audit_export_sheet.dart # (planned) gated
└── widgets/
├── audit_table.dart
├── audit_entry_row.dart
├── audit_action_chip.dart
├── audit_filter_bar.dart
├── audit_diff_view.dart
└── json_tree_view.dart
2. Dependencies
flutter_bloc, dio (AppDio), go_router, get_it, intl (dates),
flutter_secure_storage (tokens — nothing extra module-specific). No new deps; the diff
and JSON tree are hand-rolled (07 §C) to avoid a diff-library dependency
(ponytail: compute union of keys — ~30 lines).
3. AuditRepository (read-only)
class AuditRepository {
// throws ApiException(code, status) (00-shared/11 §5)
Future<AuditPage> query(AuditFilter f, {required int page, int limit = 50});
// GET /audit-logs?page=&limit=&action=&entityType=&actorId= (audit.controller.ts:19-25)
// AuditPage { List<AuditEntry> items; int total; } // from envelope.data
Stream<DomainEventMessage> watchEvents(); // WsClient topic stream
}
queryomits empty filter params (audit.controller.ts:26-29); never sendssort/q(unsupported).watchEventssubscribes to the tenant room events (ws.gateway.ts:50,ws-bridge.service.ts:16-22); AuditCubit converts + dedupes.
4. Diff rendering (core algorithm)
List<FieldDiff> computeDiff(Map<String,dynamic>? before, Map<String,dynamic>? after) {
final keys = {...?before?.keys, ...?after?.keys};
return [for (final k in keys)
FieldDiff(
key: k,
status: before == null ? FieldStatus.added
: after == null ? FieldStatus.removed
: !deepEquals(before[k], after[k]) ? FieldStatus.changed
: FieldStatus.unchanged, // filtered out before rendering
before: before?[k], after: after?[k],
)];
}
- Render: status badge + icon (
11 §4), before struck-througherrortint → aftersecondarytint; nested values →JsonTreeView(lazy). afteralone (before == null): show all keys asaftersnapshot with header "Snapshot after action" (audit.handler.ts:42-43).- Truncate values > 120 chars with expand;
SelectableTextfor copy.
5. Table layout on desktop (≥ 1200 px)
AuditTable: customTableinsideSingleChildScrollView(horizontal)(min-width 720), verticalListView.builder(virtualized —00-shared/11 §13).- Sticky header via
SliverPersistentHeader(orTablewith separate header widget). - Columns: Time · Action · Actor · Entity · Context (
mono); tap row → detail; hover tint; arrow-key row selection (FocusTraversalGroup). - Responsive degrade:
LayoutBuilder— width < 840 px or text scale > 1.5× → stackedAuditEntryRowcards instead of table (no fixed-height rows).
6. Timeline UI (proposed)
- Vertical
CustomPaintrail + dots; entries grouped by entity/correlationId from loaded set; needs backendentityIdfilter to be meaningful (OQ-1) — gated off today.
7. Navigation
GoRoute /settings/audit→AuditListPage;GoRoute /settings/audit/:id→AuditDetailPage(guard:permissionGuard('audit.read'),00-shared/11 §6).- Deep link entry miss → lookup cap
(proposed)(OQ-4) → empty state. - RBAC
/settings/access-auditreusesAuditListPagewith preset filter (route-level,design-docs/rbac/04).
8. Localization keys
audit.list.title, audit.list.count ("Showing {shown} of {total}"),
audit.list.load_more, audit.list.empty, audit.list.filtered_empty,
audit.filter.action|entity|actor|clear, audit.filter.entity_hint,
audit.detail.actor|time|entity|trace, audit.diff.changed|added|removed|none,
audit.detail.immutable, audit.export.title|planned_note, audit.realtime.new,
audit.copy.copied. Raw action/entityType strings never translated.
9. Theme
AppTheme.light()/dark()unchanged; audit adds no tokens — diff colors use semantic aliasessuccess/error/tertiary(11 §1);monofor ids/JSON.
10. Testing
- Unit:
computeDiffpermutations; pagination math; filter→query param mapping; WS dedupe/filter-matching; DTO mapper for the{data,total}shape. - Widget: list loading/error/empty/filtered-empty; detail states; banner; table↔card switch; export sheet disabled state.
- Golden:
AuditTable,AuditEntryRow,AuditDiffView,JsonTreeView(light/dark × 3 sizes). - Integration: login → open audit → filter → detail → realtime append (mock WS).
- E2E (P0): org_admin audit journey + immutability probe (PATCH/DELETE → 404).
11. Performance
- Virtualized table/cards;
constconstructors; lazy JSON rendering (collapsed by default);RepaintBoundaryaround table; cache filter option lists; debounce actor text field 300 ms; no rebuild of full list on filter change (cross-fade only). - Profiled against
00-shared/10 §1on mid-range device (deep pages, 10k+ rows).
12. Proposals flagged to the team
- Backend: add
entityType/entityId/beforeto emitters +?entityId=filter (OQ-1) → enables real resource filtering + timeline. - Backend: return
meta(shared pagination shape) fromAuditService.query(OQ-2). - Backend: add
GET /audit-logs/:id, date-rangefrom/to,q, export endpoint (OQ-4,AUDITING.md:86-87). - Backend: enforce
audit.read(Phase-5,docs/IMPLEMENTATION_PLAN.md:241); resolve 90-day TTL vs 7-year retention (OQ-6). - Realtime: mask payloads in
WsBridgebroadcast or filter client-side (OQ-8).
01 — Product Overview (Auth Module)
- 1. Purpose
- 2. Business goals
- 3. User goals
- 4. Stakeholders
- 5. Why this exists
- 6. Dependencies
- 7. Success metrics
- 8. Edge cases
- 9. Assumptions (module)
- 10. Open questions (module-level; global ledger in 00-shared/12)
- 11. Glossary (this module)
StudyLyon — multi-tenant ERP / School Management API. This package designs the Auth module client (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, events, rate limits, and wire contracts are derived directly from
src/modules/auth/**,src/common/**,src/modules/rbac/**, andsrc/infrastructure/**. No feature is invented; gaps are flagged in the Assumptions & Open Questions section.
1. Purpose
Auth is the entry gate to every StudyLyon surface. It establishes who you are (identity), which tenant you act in (multi-tenancy), which roles/permissions you carry (RBAC), and manages the security artifacts that keep every subsequent request safe: JWT access/refresh token pairs, session registry, password credentials (Argon2id), email verification, TOTP 2FA, and scoped API keys for machine access.
| Responsibility | Source |
|---|---|
| Email + password login → JWT access/refresh pair | auth.service.ts:123 login() |
| Refresh rotation with replay protection | auth.service.ts:168 refresh() |
| Logout / logout-all / session revocation (device mgmt) | auth.service.ts:199-206, 387-394 |
| Registration (first org admin), org seeding, welcome email | auth.service.ts:54 register() |
| Email verification + resend | auth.service.ts:208 verifyEmail() |
| Forgot / reset password (1 h token; revokes all sessions) | auth.service.ts:256, 285 |
| TOTP 2FA enable / verify / disable | auth.service.ts:320-385 |
| API keys issue / list / revoke (key shown once) | auth.service.ts:396-441 |
| Brute-force counters (failed attempts, lock field) | auth.service.ts:133-143, auth-account.schema.ts:30-34 |
| Domain events → BullMQ (email, audit) | auth-events.ts, event-queue-map.ts:7-13, email.worker.ts |
2. Business goals
| Goal | Measure |
|---|---|
| First log-in in seconds | Login round-trip < 1.5 s p95 (auth-account + RBAC role lookup + token sign) |
| Zero account-existence leaks | identical 401 message for unknown email vs wrong password (auth.service.ts:125,131,141) |
| Safe session lifecycle | refresh rotation: old session deleted before new pair issued (auth.service.ts:190-196) |
| No usable credential leaked at rest | Argon2id password hashes (auth.service.ts:71,298); SHA-256 refresh hashes (auth.service.ts:494); API key SHA-256 hash (auth.service.ts:403) |
| Operational resilience | auth endpoints rate-limited (per-tier), token TTLs 15m/7d (env.ts:29,34) |
| Enterprise security posture | mandatory email verification + 2FA per tenant (target policy, see OQ-1) |
3. User goals
- Anonymous visitor: register a new institution + admin account in one step, land inside with working tokens (
registerreturns tokens at institution). - School admin: daily painless login, device overview + per-device logout, 2FA on, keys to integrations.
- Teacher / Staff / Parent / Student: quiet, reliable login; password reset that survive lost passwords; never re-enter credentials more than once a session.
- Platform admin: cross-tenant tool access via
platform_adminrole; impersonation is(planned).
4. Stakeholders
Platform operator, institution admins, teachers/staff, parents, students, M2M integrators (API keys), audit/security board, support staff (reset complaints), QA + design + engineering.
5. Why this exists
StudyLyon is multi-tenant: one codebase, thousands of schools. A flawed auth gate is the single highest-risk component (credential leaks, cross-tenant access, account takeover). This module is the flagship because every other module trusts its tokens.
6. Dependencies
| Dependency | Role | Source |
|---|---|---|
| Users module | identity profile (first/last/displayName, email) | users/schemas/user.schema.ts |
| RBAC module | seeded roles + member role resolution into JWT claims | rbac.service.ts, role.schema.ts:8 |
Config (env.ts) | JWT secrets/ttl, TOTP_ISSUER (StudyLyon), SMTP, rate-limit | config/env.ts:28-53,76-80 |
| Redis | rate-limit sliding window (rl:{tier}:{ip}) | rate-limit.guard.ts:40; rate-limit.constants.ts |
BullMQ emails | welcome + password-reset email delivery | event-queue-map.ts:7,9, email.worker.ts |
BullMQ audit-write | UserLoggedIn → audit log job | event-queue-map.ts:8 |
| IdempotencyService | job dedup | idempotency.service.ts |
| Mongo collections | auth_accounts, user_sessions, api_keys | blueprint 04-Modules/Auth.md |
7. Success metrics
- Login success rate ≥ 99.5% of attempts (excl. intentional 401).
- Session refresh success ≥ 99% (token rotation works with no user-visible 4xx on interstitial).
- Password reset → logged in within 5 min for ≥ 99% of resets (email delivery + flow).
- 2FA enablement completion rate ≥ 80% of admins within 30 days of onboarding.
- Zero auth-related security incidents (account enumeration, cross-tenant, token reuse).
- Rate-limit 429 correctly shown (never silent retry loops) on auth endpoints.
- API-Key creation → shown once (raw key never recoverable from any list).
8. Edge cases
- Unknown email vs wrong password → identical 401 (
auth.service.ts:125,131). - Locked account → 401 "Account locked. Try again later." (
auth.service.ts:133-135) — lock expiry honored but no producer setslockedUntil(OQ-2). - Expired refresh used → session deleted → 401 "Session expired." (
auth.service.ts:185-190). - Reused/rotated refresh token → hash not in store → 401 "Session not found." (replay-safe by rotation; family revocation not implemented — OQ-3).
- Reset password → all existing sessions deleted → user must log in on every device (
auth.service.ts:306). - Verify+resend: verify already verified → 200 "Email already verified." (
auth.service.ts:215). - TOTP enable twice → 400; verify w/o enable → general → 400 "Call enable first."; wrong code → 401 (
auth.service.ts:326,350-356). - Register duplicate email → 409 (
auth.service.ts:57). - Logout with missing refresh token → controller throws generic
Error→ 500 (OQ-4). - Rate-limit 429 → client countdown; global guard active only in production (
rate-limit.guard.ts:30). - List API keys: only non-revoked returned (
api-key.repository.ts:22).
9. Assumptions (module)
- Mobile client is forward-looking: backend is complete; this package is the UI-side spec.
- "Email verification mandatory before first login" is the target FIXED WIRE contract
(
docs/blueprint security policy) but is not enforced byauth.service.tstoday —register()mints tokens immediately andlogin()never checksemailVerified(auth.service.ts:54,123). UI must anticipate a "verify your email" interstitial and hard log-in gating as soon as the server enforces it (OQ-1). - 2FA challenge during login (a "2FA step" after password) is not implemented in the
backend; login returns tokens directly. Current code only supports 2FA as an account
setting (enable/verify/disable). The 2FA-challenge screen is speced as
(planned)/(forward-looking)and should only be reached when the server returns a challenge signal. - Registration carries
tenantIdin the body (register.dto.ts:26-29) — a deliberate exception to "tenantId from token" for the tenant-bootstrap flow. - No QR-code backend endpoint exists — the 2FA QR image is generated on-device from the
returned
secret/qrCodeUri(totpqrCodeUriis anotpauth://URI,auth.service.ts:330).
10. Open questions (module-level; global ledger in 00-shared/12)
| # | Item | Impact |
|---|---|---|
| OQ-1 | Server does not gate first login on verified email, and does not emit a 2FA challenge at login. When is the server-side enforcement + challenge added? | Login screen gating, 2FA-challenge route timing |
| OQ-2 | lockedUntil/failedLoginAttempts are stored+incremented but no threshold ever sets lockedUntil — permanent state? Add lockout-on-N? | Lock-screen copy, retry UX |
| OQ-3 | Refresh reuse: rotation detected (old token fails re-use) but "revoke entire family" is not implemented — add? | Security messaging on devices |
| OQ-4 | logout with missing body token throws generic Error (→500) instead of 400 | Client must never send empty token; error mapping |
| OQ-5 | No role/permission metadata on auth endpoints (any authenticated user can revoke any session by id) — ownership guard (planned) | Sessions screen permission UX |
11. Glossary (this module)
| Term | Meaning |
|---|---|
| AccessToken | 15 m JWT, {sub, tenantId, roles, type:'access', jti} |
| RefreshToken | 7 d JWT, {sub, tenantId, roles, type:'refresh', jti}; stored hashed |
| Session | user_sessions doc — one per token pair; device/browser/os/ip/location metadata |
| TOTP | Time-based one-time password (default: SHA1, 6 digits, 30 s, issuer StudyLyon) |
| API key | sk-style 64-hex raw value shown once; prefix (first 8 chars) + keyHash stored |
| Rate tier / slash | Redis sliding-window per-IP limit on auth/api/public/admin |
| Envelope | {success,message,data,meta?,timestamp,requestId} |
| jti | per-token-pair UUID tying access+refresh (auth.service.ts:458,460) |
02 — User Personas (Auth Module)
- 1. Anonymous Visitor (prospect / pre-registration)
- 2. School Admin (org_admin) — flagship user
- 3. Teacher / Staff / Accountant / Parent / Student (low-permission roles)
- 4. Platform Admin (platform_admin, cross-tenant)
- 5. M2M Integrator (API key consumer)
- 6. Visitor who abandons registration
- 7. Role→auth-appearance matrix
Roles, goals, permissions, and expected behavior for every consumer of the Auth module. Permission values are exact from
rbac/schemas/role.schema.ts:8(DEFAULT_ROLES) andrbac/permissions.constants.ts(ALL_PERMISSIONS). Auth self-service endpoints (/auth/sessions,/auth/api-keys,/auth/2fa/*,/auth/logout-all) require only a valid JWT — no permission metadata (OQ-5). What differs is which permissions a role carries in the rest of the product.
1. Anonymous Visitor (prospect / pre-registration)
| Attribute | Value |
|---|---|
| Frequency | One-time; 10–15 min journey |
| Goal | Register their school + admin account, get tokens, verify email, enable 2FA |
| Pain points | Typo'd emails, lost verification emails, rate-limit 429 on retry storms |
| Core job | POST /auth/register (firstName,lastName,email,password≥8,tenantId) → tokens in data.accessToken/refreshToken |
| Restrictions | Rate auth 5/min (auth.controller.ts:31); unknown tenantId accepted (created with that id) |
| Expected behaviour | Immediate login after submit; "verify your email" prompt (target contract), 2FA onboarding nudge |
| Mental model | "Fill form → I'm in → I'll confirm email from inbox" |
2. School Admin (org_admin) — flagship user
| Attribute | Detail |
|---|---|
| Frequency | Several times/day, many devices |
| Permissions | ALL_PERMISSIONS (role.schema.ts:23) — org owner |
| Goal | Fast secure login, full device visibility, harden account with 2FA, API keys for integrations |
| Pain point | Account lockouts, 2FA device loss, "unknown device" logins they didn't make |
| Expected | Login → home; sessions screen lists all devices w/ browser/os/ip/location; per-device logout (DELETE /auth/sessions/:id), logout-all (POST /auth/logout-all), 2FA enable/verify/disable, API-key create→show-once |
| Restrictions | Revocation is by session id with no ownership check (OQ-5); keys revoked individually |
| Mental model | "Who's currently me? What to cancel?" |
3. Teacher / Staff / Accountant / Parent / Student (low-permission roles)
| Attribute | Detail |
|---|---|
| Permissions (from roles) | teacher ['student.read','attendance.mark','attendance.edit']; staff ['student.read']; accountant ['fees.collect','student.read']; parent ['student.read']; student [] (role.schema.ts:31-42) |
| Frequency | 1–3 logins/day phone/tablet |
| Goal | "Just let me in", password reset without losing the day |
| Pain points | Forgot password over weekends; refresh expiry mid-work → single silent refresh; push→deep link login |
| Expected | Remembered device → auto-login (secure storage + refresh); reset flow intuitive step 1→2→3 |
| Restrictions | Registration flow is opaque (their account is created by admin via users); no tenant selection necessary — token carries tenant |
| Mental model | "One account, don't ask me again if possible" |
| Auth-specific note | These roles never touch sessions/2FA/API keys normally (UI hides the Security section except self-service password). |
4. Platform Admin (platform_admin, cross-tenant)
| Attribute | Detail |
|---|---|
| Permissions | permissions: [] but JwtAuthGuard sets isPlatformAdmin=true when role present (jwt-auth.guard.ts:54) — bypass seed |
| Frequency | On-demand support; diagnostics |
| Goal | Resolve tenant issues; impersonation (planned); never logged into spec-touched accounts |
| Restrictions | Must hold a real tenant token; cross-tenant token on a tenant → tenantId mismatch → guard re-scopes ctx.tenantId from token (jwt-auth.guard.ts:44-55) |
| Expected | Session revocation on a user's behalf is not provided by AuthController today (future impersonate/unlock endpoints (planned) IMPLEMENTATION_PLAN.md) |
5. M2M Integrator (API key consumer)
| Attribute | Detail |
|---|---|
| Auth | Authorization: Bearer <apiKey> (raw key); keys never logged |
| Goal | Scoped machine access: library sync, fee batch, report pull, transport |
| Permissions | Key scopes[] free-form strings from CreateApiKeyDto (create-api-key.dto.ts:12); not yet checked by any guard — scoping is metadata today (OQ) |
| Restrictions | POST /auth/api-keys (name, scopes?) returns {id,prefix,key} — key visible once only (auth.service.ts:414); revoke via DELETE /auth/api-keys/:id |
| Lifecycle | listApiKeys → {id,name,prefix,scopes,createdAt,lastUsedAt} (never full key); lastUsedAt updated elsewhere (api-key.repository.ts:35) |
6. Visitor who abandons registration
| Attribute | Detail |
|---|---|
| Frequency | Registration abandons |
| Behavior | May return via /verify-email token; may retry register → 409 duplicate; may reset password on the pre-verify account |
7. Role→auth-appearance matrix
| Value | org_admin | teacher/staff/accountant/parent/student | platform_admin | API-key consumer |
|---|---|---|---|---|
| Login screen | ✓ | ✓ | ✓ | — (uses key) |
| Email verify interstitial (target) | ✓ (on register) | via invite flow | — | — |
| 2FA onboarding | recommended | optional | n/a | — |
| Sessions screen | ✓ | ✓ (self) | ✓ | — |
| API keys screen | ✓ | admin may | ✓ | views on behalf? no |
| Security settings (change password, etc.) | ✓ | ✓ (self) | ✓ | — |
Use PATCH /users/:id/preferences for theme/language | ✓ | ✓ | ✓ | — |
Roles/goal frequency: shared baseline 00-shared/01. Every persona's auth-facing needs collapse to: log me in, keep me in (silent refresh), let me back in (reset), and don't scare me. The module's killer features (session map, 2FA, key vault) are the admin-facing differentiators.
03 — User Journeys (Auth Module)
- 1. Login
- 2. First-login email verification
- 3. TOTP 2FA — setup & challenge
- 4. Forgot / reset password
- 5. Logout
- 6. Silent refresh / session expiry (token lifecycle)
- 7. Device / session management
- 8. API key management
- 9. Tenant onboarding → first login (register)
- 10. Cross-cutting: deep links, push, QR, email
End-to-end journeys for the Auth module computed from
auth.controller.ts+auth.service.ts. Each journey: entry, intent, decision points, system responses, loading, failures, recovery, exit, back navigation, abandonment, timeout, session expiry, permission denial, offline.(planned)/(forward-looking)marks per global rules.
1. Login
entry: warm/cold app open, deep link, push→content, session-expiry redirect
intent: obtain an authenticated context
sequenceDiagram
actor U as User
participant F as LoginScreen
participant R as AuthRepository
participant API as POST /auth/login
U->>F: enter email + password
F->>R: submit()
R->>API: {email, password}
alt success (200)
API-->>R: envelope.data = {accessToken, refreshToken}
R-->>F: persist tokens (secure storage) + session meta
F-->>U: navigate /home (role-aware)
else 401 UNAUTHENTICATED
API-->>R: "Invalid email or password."
F-->>U: field-level error, inline (never reveals which field wrong)
else 429 RATE_LIMITED
F-->>U: countdown, no auto-retry
else network
F-->>U: AppErrorState + Retry
end
- Decision points: remember this device? → uses refresh in secure storage for auto-session;
2FA challenge if server returns one
(planned). - Loading: button
loadingspinner replaces label; anti double-submit (08_Interaction_&_Motion.md §6). - Failure covers: wrong password, locked account ("Account locked. Try again later." —
auth.service.ts:135), unknown email (identical text), rate limit, offline. - Exit: successful →
/home(per-role landing,00-shared/05 §2); back = quit. - Abandonment: back stack cleared after success; token loss →
sessionExpired→ re-login. - Password managers: autofill hints
username+current-password,autofillHintson TextFields (09_Accessibility_Baseline.md §10).
2. First-login email verification
entry: in-app banner post-register, email click (deep link /login) — target contract
intent: satisfy "email verification mandatory before first login" (FIXED WIRE CONTRACT; OQ-1)
sequenceDiagram
actor U as New user
participant E as Email client
participant W as EmailWorker (BullMQ emails)
participant API as POST /auth
U->>API: POST /auth/register
API-->>W: UserRegistered -> emails/send-welcome-email
W-->>E: Welcome email, token=verificationToken
U->>E: see verify link (deep link /verify-email?token=...)
E-->>F: open app VerifyEmailScreen
F->>API: POST /auth/verify-email {token}
alt token valid
API-->>F: 200 {message:"Email verified successfully."} (auth.service.ts:235)
else token invalid/expired
API-->>F: 400 "Invalid or expired verification token."
end
F-->>U: success (check) → /login or /home
- Resend:
POST /auth/resend-verification(JWT) — 5/120s rate (auth.controller.ts:83-89). - Already-verified token: 200 "Email already verified." (
auth.service.ts:215) — idempotent client handling. - Recovery: invalid token → inline error + "resend email" (rate-limited) + deep-link fallback.
3. TOTP 2FA — setup & challenge
2FA setup is fully implemented; login challenge is (planned) (server returns no
challenge signal today — OQ-1).
sequenceDiagram
actor U as Admin
participant S as SecuritySettings
participant API1 as POST /auth/2fa/enable
participant API2 as POST /auth/2fa/verify
U->>S: "Set up two-factor authentication"
S->>API1: (JWT)
API1-->>S: {secret, qrCodeUri} (otpauth URI, auth.service.ts:330)
S-->>U: QR + manual `secret` — Save (copy) + pickup
U->>APP: open authenticator, scan
U->>S: enter 6-digit code
S->>API2: POST /auth/2fa/verify {token}
API2-->>S: 200 {message:"2FA enabled successfully."}
S-->>U: green confirmation; TOTP badge ON
Note over U: next login, issuer StudyLyon
- Failure: wrong code → 401 "Invalid TOTP code."; already enabled → 400 "already enabled.";
verify before enable → 400 "Call enable first." (
auth.service.ts:326,350-353). - Disable: needs code prompt →
POST /auth/2fa/disable(verified action). - Loss of device: no recovery codes today
(planned)(IMPLEMENTATION_PLAN.md§Phase 4/mfa/recovery-codes) → route to account recovery / admin un-enroll(planned).
4. Forgot / reset password
entry: "Forgot password?" on login; privacy: indistinguishable for unknown email
sequenceDiagram
actor U as User
participant F as ForgotPasswordScreen
participant R as AuthRepo
participant API1 as POST /auth/forgot-password
participant E as Email (TOKEN reset link)
participant P2 as ResetScreen
participant API2 as POST /auth/reset-password
U->>F: email
F->>API1: {email}
API1-->>F: 200 — identical body for existing/missing user
API1->>E: PasswordResetRequested → emails worker, token 32B hex, 1h TTL
U->>E: click link → app deep-link /reset-password?token=...
P2->>API2: POST /auth/reset-password {token, newPassword(≥8)}
API2-->>P2: 200 {message:"Password reset successfully."} — all sessions revoked
P2-->>U: "Log in with new password" → /login
- Rate: forgot 3/min, reset 5/min (
auth.controller.ts:92-107). - Timeout: token expires 1 h (
auth.service.ts:266,passwordResetExpiresAt) → 400 "Reset token has expired." → back to forgot. - Reuse: after successful reset
passwordResetTokencleared (auth.service.ts:303); re-use → 400 "Invalid or expired reset token." - All devices: reset deletes every session — user must log in on every device (
auth.service.ts:306).
5. Logout
entry: settings → "Log out" (all), per-device “Log out” from Sessions
sequenceDiagram
participant U as User
participant S as SecurityPage
participant API as POST /auth/logout|logout-all
S->>API: logout (refreshToken) // delete 1 session by hash
S->>API: logout-all // (JWT) deletes ALL sessions (user)
API1-->>S: 200 {message:"Logged out successfully."}
API-->>S: 200 {message:"Logged out from all devices."}
S-->>U: clear local tokens → /login (+ "You have been logged out" snackbar)
- Permission denial: logout-all is JWT-only; unauthenticated → 401 → redirect login.
- Risks documented: the controllers' logout requires
refreshToken— an empty token throws arbitraryError→ 500 (OQ-4); client must never submit blank. - Exit: no back into app shell.
6. Silent refresh / session expiry (token lifecycle)
stateDiagram-v2
[*] --> Authenticated: login
Authenticated --> Refreshing: accessToken expires
Refreshing --> Authenticated: POST /auth/refresh ok
Refreshing --> SessionExpired: refresh fails (reuse/expired/revoked)
SessionExpired --> Login: show reason banner
Login --> Authenticated: fresh login
Authenticated --> LoggedOut: logout / revoked on another device
- Single-flight refresh on 401 interceptor (
diorefresh interceptor,00-shared/11 §5). - Refresh rotation is implicit: every successful refresh yields a new pair; old refresh
is revoked server-side (
auth.service.ts:190-196). - Expired refresh (4xx) →
SessionExpired→ re-login + reason snackbar (if known).
7. Device / session management
entry: Settings → Security → Devices
GET /auth/sessions → list (deviceName/browser/operatingSystem/platform/ipAddress/location
from user_sessions, created/expires/lastActivity)
- Item → "Log out this device" → confirm dialog →
DELETE /auth/sessions/:id. - "Log out all other devices" (would be
(planned)— today there's only logout-all-like and revoke-by-id on the current user). - Data note: session
listreturns all of the current user's sessions sorted as-in-insertion (no page param — small list, client renders grouped by device).
8. API key management
entry: Security → API keys
- List:
GET /api/v1/api-keys→ cards (name, prefixabc12345…, scopes, createdAt, last); only active. - Create:
POST /api/v1/api-keys→ one-time modal shows full key (copy); never re-displayed. - Revoke:
DELETE /api/v1/api-keys/:id→ confirm.
9. Tenant onboarding → first login (register)
entry: `/register` (fresh install / SaaS landing)
POST /auth/register→ tokens returned → already authenticated in tenant.- Decision: some tenants may want admin to pick plan/channel before auth — no signal; assume immediate.
10. Cross-cutting: deep links, push, QR, email
| Entry | Behavior | Status |
|---|---|---|
Deep link studylyon://verify-email?token= / /reset-password?token= | parse token, preload form | (forward-looking) client; no server link endpoint |
| Push → "session revoked on another device" | openssessions; re-auth | (planned) push infra 00-shared/12 B3 |
| QR scan for 2FA | only 2FA setup QR display (generated client-side); scanner not needed | display-only |
Abandonment & exit rules (all): back = cancel token capture; timeout = 15 min idle reset token expired → 400; permission denial impossible within module (self-service); offline = cached verify/reset state only, actions blocked (write queue not defined for auth).
04 — Information Architecture (Auth Module)
- 1. Two top-level zones
- 2. Route table (module-owned; extends
00-shared/05 §4) - 3. Screen hierarchy (pre-auth)
- 4. Modal hierarchy (module)
- 5. Screen relationships
- 6. Nested nav
- 7. Quick actions / FAB / context menus
- 8. Deep links (proposed routing table)
- 9. Accessibility of IA
- 10. Peripheral: which nav elements are Auth-owned
Where Auth screens live in the shell (00-shared/05). Auth owns the pre-shell public stack (routes reachable while unauthenticated) and the Security section inside the authenticated app. Everything is derived from
auth.controller.ts(routes) and the shared shell routing table.
1. Two top-level zones
App
├─ PRE-AUTH (no shell, no NavigationBar) │ POST-AUTH (AppShell)
│ ├─ /login │ └─ /settings
│ ├─ /login/2fa (planned) │ └─ Security hub
│ ├─ /register │ ├─ /settings/security/2fa
│ ├─ /verify-email │ ├─ /settings/security/devices
│ ├─ /forgot-password │ └─ /settings/security/api-keys
│ └─ /reset-password
- Pre-auth zone: no bottom NavigationBar, no global AppBar chrome — single-column, brand-first layout, keyboard-friendly, minimal distraction.
- Post-auth Security section lives under
/settings(auth-gated byJwtAuthGuard).
2. Route table (module-owned; extends 00-shared/05 §4)
| Route | Screen | Auth | Source API |
|---|---|---|---|
/login | Login | public | POST /auth/login |
/login/2fa | 2FA challenge | public, token-carrying (planned) | future challenge |
/register | Tenant registration | public | POST /auth/register |
/verify-email | Email verify (+result) | public | POST /auth/verify-email |
/forgot-password | Forgot | public | POST /auth/forgot-password |
/reset-password | Reset | public | POST /auth/reset-password |
/settings/security | Security hub | JWT | — |
/settings/security/2fa | 2FA status + setup | JWT | /auth/2fa/enable,/2fa/verify,/2fa/disable |
/settings/security/devices | Sessions/devices | JWT | GET/DELETE /auth/sessions(/:id) |
/settings/security/api-keys | API key list | JWT | GET/POST/DELETE /auth/api-keys(/:id) |
/lock | App lock screen ((forward-looking)) | device biometry | none |
3. Screen hierarchy (pre-auth)
- Login is the hub:
→ /forgot-password,→ /register,→ /login/2fa(after a login that signals challenge, planned),← /reset-password(success → login),← /verify-email(success → login). - Registration is a full-screen page (multi-field form
> 3 fields→ page, not dialog —00-shared/05 §5). - Verify/reset are token-injection pages: they accept
?token=from deep link/email and can be re-invoked from banner buttons.
flowchart TD
L[/login/] --> F[/forgot-password/]
L --> R[/register/]
L --> TOTP[/login/2fa/]
F --> V[/reset-password/]
R --> VE[/verify-email/]
VE --> L
V --> L
subgraph authed security
SEC[/settings/security/] --> FA[/settings/security/2fa/]
SEC --> DEV[/settings/security/devices/]
SEC --> AKEY[/settings/security/api-keys/]
end
4. Modal hierarchy (module)
Following 00-shared/05 §5:
- Snackbar/toast — "Password reset email sent", "Logged out", "Copy", "Key revoked".
- Bottom sheet — API-key one-time reveal (needs full attention), session device detail.
- Dialog — destructive confirms (revoke key, log out this device, disable 2FA), typed confirm only for irreversible (none in module — all revocable actions use plain confirm).
- Full-screen page — register (5 fields), login, reset.
- Loader — button spinner (submit), never full-screen dual column.
5. Screen relationships
- Login → (success) →
authenticatedswitch → shell home for role (00-shared/05 §2). - Security hub → subpages return with changed state (2FA now on; key created; count of sessions changed) — parent cubit refreshes on return.
- Logout anywhere → global
AuthCubitpivot →/loginwith reason.
6. Nested nav
- Tablets/desktop: Security uses master-detail (
/settings/securitylist left pane, details right); phone: push (module routes push like globals). - No
StatefulShellBranchneeded across zones; pre-auth stack is a simpleNavigator.
7. Quick actions / FAB / context menus
- No FAB in auth zone (forms are the surface). Security lists: row
AppMenu(Device → Log out, Details; API key → Copy prefix, Revoke). - Keyboard: pre-auth Enter in password field submits login (
08 §2);Escclears errors/closes sheets;Ctrl+Ksearch n/a pre-auth.
8. Deep links (proposed routing table)
| Pattern | Target | Note |
|---|---|---|
studylyon://login | Login | warm start |
studylyon://verify-email?token=… | Verify email | token prefill |
studylyon://reset-password?token=… | Reset | token prefill |
studylyon://settings/security | Security hub | JWT required |
9. Accessibility of IA
- One
headlineMediumper screen; pre-auth forms ordered email→password→CTA. - Focus begins at first input; back gesture = previous auth page (never shell home).
10. Peripheral: which nav elements are Auth-owned
Shell-owned cross-cutting screens (logout, account avatar) are preserved from
00-shared/05 §8; Auth adds only the Security hub under settings and the pre-auth
gate zone. State of the gate (unauthenticated) hides all authenticated routes.
05 — Screen Inventory (Auth Module)
- Legend
- 1. Login Screen (
/login) - 2. 2FA Challenge Screen (
/login/2fa)(planned) - 3. Register Screen (
/register) - 4. Verify Email (
/verify-email) + result - 5. Forgot Password (
/forgot-password) - 6. Reset Password (
/reset-password) - 7. Tenant Registration Result (
/verify-emailcovers most) — same as 3/4 - 8. Security Hub (
/settings/security) — authenticated - 9. Sessions / Devices Screen (
/settings/security/devices) - 10. API Keys — List (
/settings/security/api-keys) - 11. API Keys — Create (bottom sheet)
- 12. API Keys — Revoke dialog
- 13. Security Settings — 2FA detail (
/settings/security/2fa) - 14. Lock Screen (
/lock)(forward-looking) - Shared components used
- Analytics events (proposed)
- Keyboard, landscape, tablet, desktop
Every screen of the Auth module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics.
Legend
States = idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission.
Analytics events follow {module}.{screen}.{action} (proposed; SDK open — 00-shared/10 §8).
1. Login Screen (/login)
| Field | Detail |
|---|---|
| Purpose | Authenticate with email/password → token pair |
| Entry | cold app, any deep link, session expiry, "log out" piggyback |
| Exit | success → /home; → /forgot-password; → /register; → /login/2fa (planned) |
| Inputs | AppTextField email (autofill username), AppTextField password (obscure, autofill current-password) |
| Primary CTA | Login filled button (fullWidth) |
| Secondary | "Forgot password?" (text), "Register your school" (text) |
| States | loading (button), error (inline field/server), rate-limited (429 countdown), offline (banner+native retry), locked (specific copy) |
| Analytics | auth.login.submit, auth.login.success, auth.login.failure(code) |
| Keyboard | email → .emailAddress; password → .visiblePassword-ish + onSubmit |
| Adaptive | phone single pane; tablet/desktop centered card ≤ 480 dp with brand mark |
| a11y | labels linked, error live-region, focus first invalid |
| Motion | m-fast focus; error shake; success → swap to home m-base slide |
2. 2FA Challenge Screen (/login/2fa) (planned)
| Field | Detail |
|---|---|
| Purpose | Second factor check after password (server-dependent — blocked OQ-1) |
| Entry | from login challenge signal |
| Exit | success → /home; failure → same screen with inline TOTP error |
| Input | one 6-digit TOTP field (auto-advance) |
| Primary CTA | Verify |
| States | loading, invalid code (401 → clear + focus), rate-limited; "remember device" toggle (client prefs only, no server flag) |
| a11y | single semantics label "6-digit verification code"; keyboard number |
| Note | Server returns no challenge today: feature is forward-looking; keep route dormant |
3. Register Screen (/register)
| Field | Detail |
|---|---|
| Purpose | Create org admin + tenant in one step (server assigns org_admin) |
| Entry | /login "register", fresh install |
| Exit | success → token stored → /home (+ "verify your email" banner) |
| Inputs | firstName, lastName, email, password (min8), confirm (client-only), tenantId, phone optional |
| Auto-fill | given-name, family-name, email, new-password, tel |
| Primary CTA | Create account |
| Secondary | "Back to login" |
| States | as login; plus 409 duplicate (email) inline |
| Deep note | Registration returns tokens immediately → user is authenticated; duplicate email → ConflictException 409 (auth.service.ts:57) |
| Content | informational "what happens next" list (verify email, 2FA prompt) |
4. Verify Email (/verify-email) + result
| Field | Detail |
|---|---|
| Purpose | Consume verification token from email link/banner |
| Entry | deep link /login/?token=; auto-fires; banner direct-entry |
| Exit | success → /home (or /login); error → form with "resend" |
| Input | implicit token (hidden) |
| Primary CTA | Verify email (auto-submit on prefilled token); Resend email |
| States | verifying (spinner), success check, error invalid/expired, resend throttled (5/120s) |
| Adaptive | centered card; scroll when keyboard |
| a11y | success announced via live region |
5. Forgot Password (/forgot-password)
| Field | Detail |
|---|---|
| Purpose | Request one-time reset email (never reveals existence) |
| Input | AppTextField email |
| Primary CTA | "Send reset link" |
| States | success screen-as-message identical for existing/missing (privacy auth.service.ts:259-282); rate (3/min) |
| Note | design should play "email sent" neutrally |
6. Reset Password (/reset-password)
| Field | Detail |
|---|---|
| Purpose | Set new password with 1 h token; revokes all sessions |
| Input | password (min8), confirm |
| Primary CTA | "Set new password" |
| States | success → login redirect; token expired/invalid 400 (inline + "request new link") |
| Security note | session-array cleared server-side (auth.service.ts:306); login requires manual re-setup of any clients |
7. Tenant Registration Result (/verify-email covers most) — same as 3/4
8. Security Hub (/settings/security) — authenticated
Table rows (AppListTile): Two-factor authentication (status switch, badge on/off), Devices & sessions (count badge), API keys (count badge). Master-detail on tablets.
9. Sessions / Devices Screen (/settings/security/devices)
| Field | Detail |
|---|---|
| Purpose | List current user's active sessions |
| Route | /settings/security/devices (list) + sheet for detail |
| Source | GET /auth/sessions → array of documents (deviceName, browser, operatingSystem, platform, ipAddress, location, expiresAt, lastActivityAt) |
| Widgets | ListTile group (current device labeled "You"), per-item icon inherits platform |
| Pull-to-refresh | RefreshIndicator |
| Empty | "No other devices" |
| Row actions | AppMenu → "Log out this device" (DELETE /auth/sessions/:id) with confirm dialog |
| CTA | bottom button "Log out all devices" (POST /auth/logout-all) |
10. API Keys — List (/settings/security/api-keys)
| Field | Detail |
|---|---|
| Source | GET /auth/api-keys |
| Widgets | AppCard per key: prefix…, scopes chips, createdAt / lastUsedAt, menu (revoke) |
| FAB | FAB "New key" (one per screen) |
11. API Keys — Create (bottom sheet)
| Field | Detail |
|---|---|
| Purpose | two-step: form → one-time secret reveal |
| Form | name (required), scopes chips (optional, free-text input chip) |
| Reveal | full raw key + Copy + "I've saved it" (server never re-serves) |
| Source | POST /auth/api-keys returns {id, prefix, key} — key shown once |
12. API Keys — Revoke dialog
Confirm + DELETE /auth/api-keys/:id → snackbar; item leaves list (optimistic, rollback on fail).
13. Security Settings — 2FA detail (/settings/security/2fa)
| Field | Detail |
|---|---|
| Status tile | TOTP on/off (badge) |
| Enable flow | POST /auth/2fa/enable → QR + secret, copy; input 6-digit → POST /auth/2fa/verify → set ON (server sets enabled only after this) |
| Disable flow | input current code → POST /auth/2fa/disable |
| States | secret visible once?; turn-off needs typed code |
14. Lock Screen (/lock) (forward-looking)
- App-level: biometric/Whisper unlock of locally-stored session; style consistent with
settings.read-gated surfaces; no server API.
Shared components used
AppTextField, AppButton, AppSnackbar, AppCard, AppListTile, AppBottomSheet,
AppDialog, AppMenu, AppSkeleton, AppEmptyState, AppOfflineBanner, AppFAB,
AppChips, AppBadge, AppAvatar. Module-specific: 2FA code field, secret reveal card, session tile — defined in 07_Component_Library.md.
Analytics events (proposed)
auth.login.{submit,success,failure}, auth.register.{submit,success,duplicate},
auth.verify.{success,invalid}, auth.reset.{sent,completed,expired},
auth.2fa.{enabled,disabled,verify_failed}, auth.session.{list,revoke,logout_all},
auth.apikey.{create,reveal,revoke} (all proposed).
Keyboard, landscape, tablet, desktop
- Pre-auth: portrait-first; landscape uses scroll; tablet/desktop constrain width.
- Sessions/api-keys on tablet: two-column list/detail; desktop hover row highlights.
- All fields keyboard-avoidance +
ScrollViewresizeToAvoidBottomInset.
06 — Screen Specifications (Auth Module)
Production-grade specs for each Auth screen: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens:
Sp= 00-shared/02 §3,Ty= typography §2,El= elevation §5,R= radius §4,Mo= motion §6. All components reference 00-shared/03.
1. Login Screen /login
Chrome: no AppBar (brand only); optional app-logo AppAvatar/logo lockup; screen
scrolls with keyboard avoidance (resizeToAvoidBottomInset: true); horizontal padding 16
(phone), 24 (tablet+).
Layout (phone portrait):
Brand lockup [ primaryContainer block, 64h ]
title "Welcome back" headlineMedium
AppTextField email h 56, r-md
AppTextField password h 56, r-md, obscure, autofill
[toggle show/hide password] IconButton 24
Primary CTA "Log in" FilledButton fullWidth h 48
row: "Forgot password?" TextButton
divider "or"
TextButton "Register your school"
Legal microcopy bodySmall onSurfaceVariant
States:
- idle → email/password empty, CTA disabled.
- filled → CTA enabled (no server call until tap).
- submitting → CTA
loadingspinner replaces label, inputs disabled, no double-submit. - error inline (401): error text under field(s)
errorContainerchip + "Invalid email or password." — server doesn't reveal which; copy matches serverauth.service.ts:125. - error locked: banner "Account locked. Try again later." (
auth.service.ts:135). - rate limited 429: inline
AppBanner(error)+ countdownlabelMedium; inputs stay editable but CTA disabled until window; no auto-retry. - offline:
AppOfflineBannertop; CTA disabled with tooltip "Connect to retry". - password manager: autofill hint both snippets (
username,current-password).
Motion: Mo.m-fast ripple; button loading Mo.m-base; error AnimatedSwitcher fade;
screen transition to home Mo.m-base.
A11y: each labeled field, Semantics.error on invalid, error announced via live region;
focus to first invalid on submit; target ≥ 48.
Dark/light: token-only; brand lockup primaryContainer; both pass contrast.
Tablet/desktop: centered card width ≤ 480; skip-to-content; hover states on buttons;
Tab traversal email→password→CTA→forgot.
Safe areas / landscape: all padded within SafeArea; landscape ≤ 640h scrolls.
2. 2FA Challenge Screen (planned)
Layout: same hub; "Enter the 6-digit code from your authenticator"; AppTotpInput 6 boxes
(auto-advance, paste support); CTA Verify; "Use a recovery code" (planned); "Back to login".
Error: pop AppSnackbar error + red ring; auto-focus box 1 on navigation.
3. Register Screen
Header: headlineMedium "Create your school account".
Fields (in order): first name, last name, school/tenant id, email, phone (opt), password
(+ strength hint), confirm. Footer: legal microcopy + "I agree" implied by submit.
CTA "Create account" loading.. on 409 → inline banner under email + focus email.
On success (tokens auto-login, auth.service.ts:95-120): navigate home with "Verify your
email" banner (AppBanner info) + "Resend verification" (rate-limited 5/120s).
4. Verify Email Screen
Centered card; auto submit if ?token= present. States: Skeleton while token introspection
(cannot introspect — client calls verify once), success check icon success, error banner
"Link invalid or expired — resend (X s)" with POST /auth/resend-verification guarded by
rate limit countdown. Deep link token → clipboard capture → auto call.
5 & 6. Forgot / Reset Password
Same spec → see 08_Form_Specifications.md for complete field tables; screens use identical
layout chrome to Login (single card, brand lockup).
7. Security Hub
ListTile rows with Trailing = badge (count or TOTP ON), each icon map:
2FA (Icons.shield), Devices (Icons.devices, count badge = #sessions), API keys
(Icons.key, count). Desktop master-detail reveal. Refresh on return.
8. Sessions/Devices Screen
| Concern | Specification |
|---|---|
| Groups | "This device" pinned first (match on stored device fingerprint/local marker) then others |
| Row | AppListTile leading device icon by platform (phone/tablet/desktop), title deviceName, subtitle "browser · OS · location · ip", trailing AppMenu (Log out this session) |
| Date line | bodySmall "Last active 3h ago · expires in 5d" from lastActivityAt/expiresAt (user-session.schema.ts:36,39) |
| Empty | AppEmptyState icon devices_other, "No other sessions" |
| Loading | AppSkeleton(list) |
| Error | AppErrorState.code UNAUTHENTICATED/INTERNAL |
| Pull | AppRefreshIndicator |
| CTA (bottom, fullWidth) | Log out all devices → AppDialog confirm → POST /auth/logout-all |
| Row action | DELETE /auth/sessions/:id b/ revoke dialog (destructive-style) then optimistic remove + AppSnackbar + UNDO (reenable only via re-login; mark no undo — revoke is irreversible server-side) |
A11y: row semantics button + device + action hint; affordance of confirm menu; dynamic type sizing.
Dark: identical tokens; sessions differ by primaryContainer for current device.
Tablet: two-column (list+detail) master-detail at ≥840px; desktop command key Cmd+Backspace revoke (proposed).
9. API Keys List / Create / Revoke
| Screen | Spec |
|---|---|
| List | Header headlineMedium + subtitle; AppFAB "Add key" (extended, Icons.add); row AppKeyCard (AppListTile): prefix in mono, scopes as AppChips (readonly), lastUsedAt/createdAt line, AppMenu: Revoke. Empty: AppEmptyState |
| Create (bottom sheet, 2/3 height) | AppTextField name (autofocus), scopes AppChips input (optional; free text to CreateApiKeyDto.scopes? string[]), CTA "Create key"; submit loading; on 409/400 banner; success → reveal state: full-key text mono in AppCard + "Copy" button (confirmation Snackbar "Copied") + warning "Save your key — it's shown once" + Done replaces sheet. This card replaces the form; dismiss clears key |
| Revoke | AppDialog "Delete this key?" → DELETE /auth/api-keys/:id → optimistic removal + Snackbar |
10. 2FA Detail Screen
| UI | Spec |
|---|---|
| Off state (default) | AppBanner "Two-factor is off"; TonalButton "Turn on" → POST /auth/2fa/enable → returns {secret, qrCodeUri} |
| Setup stage | QR card (device-render qrCodeUri), secret mono + copy+backup note; "I've saved my secret" CTA |
| Verify stage | 6-digit TOTP input; "Enable" → POST /auth/2fa/verify; 401 → red ring + error; success → status ON with green badge; AppSnackbar "2FA enabled" |
| On state | status row, Disable (destructive text) → enables TOTP input again → POST /auth/2fa/disable (code required by server auth.service.ts:376) |
| Safe area | QR fits safe areas; landscape SingleChildScrollView |
Global dark/light, dynamic type (200%), a11y on all – used tokens are surface,
primaryContainer, outline, onSurfaceVariant; error errorContainer.
Shared micro-interaction & motion notes (all screens)
Mo-m-baseroute transitions;AppBarelevationEl-1; cardsEl-e1.- Snackbar durations: success < 3 s, error ≥ 4s (
00-shared/03 A). - No FAB on pre-auth; FAB appears only ApiKeys list, hide-on-scroll-down.
- Keyboard: default
TextInputAction.next, last field.done;Entersubmits; error fields focus jump. - Safe areas: bottom nav-height respected only in authed; pre-auth uses
MediaQuerypadding.
07 — Component Library (Auth Module)
- A. Brand & chrome
- B. TOTP & code entry
- C. Session representation
- D. API-key artifacts
- E. Status & feedback
- F. Only elements the API supports
- G. Flutter widget map (module)
- H. Golden tests (base
00-shared/03 G)
Module-specific components built from shared
App*primitives (00-shared/03). Props, states, animation, a11y, tokens, Flutter mapping. Reuse over new — only components that don't exist in the shared library are defined here.
A. Brand & chrome
AppBrandMark (Auth-specific)
- Purpose: StudyLyon lockup at top of pre-auth screens; differentiator + trust.
- Props:
expanded(bool, tablet/desktop),onTap(unused on pre-auth). - State: single, tinted (
primaryContainertile). a11y:Semantics(image, label: "StudyLyon"). - Flutter:
AppAvatar-styleCircleAvatar+Textwith themed lockup; branding override fromorganization.branding(future).
AppPasswordField
- Purpose: obscured input with reveal toggle — used in login/register/reset.
- Props:
label,hint?,validator,textInputAction,onSubmitted,autofillHints(new-password|current-password),enabled,errorText,helperText(suggested account lockspace),strengthHintoptional (register). - State: base / focus / error / disabled / loading(suffix spinner) / reveal.
- Animation:
Mo-fasticon swap; error slide-in. - a11y: label +
Semantics(error), error announced; reveal buttonSemantics(toggled:obscure). - Flutter:
TextFormField+ suffixIconButton(visibility/visibility_off) withTooltip"Show password".
B. TOTP & code entry
AppTotpInput (6-digit)
- Purpose: enter TOTP 6–8 digits (backend default 6
env.ts:78, accept 6/8 sloppy withauthenticator.checktolerant drift). - Props:
length(default 6),onCompleted(code),error,loading,autofocus,readOnly. - Behaviors: 6 boxes, auto-advance, overwrite, paste full code,
Backspaceto previous; digit-only filter. - States: empty, partial, valid, error (
errorring) — validates againstauthenticator.check(code, secret)only server-side; client validatesRegExp(r'^\d{6,8}$')locally. - A11y: single grouped field label "One-time code",
Semanticsgroups boxes; talkback reads "digit 2 of 6". - Flutter: custom
Rowof 6TextField48dp focused with movement arrows / singleTextFieldwithLetterSpacing8px + lengthFormatter (recommend single hidden-real-width input). - Motion:
Mo-fastslide between boxes; errorAnimatedSwitchershake.
AppTotpChip (status pill)
AppBadge variant: ON → success, OFF → outline.
C. Session representation
AppSessionCard
- Purpose: one authenticated device row.
- Props:
deviceName?,browser?,os?,platform?,ip?,location?,expiresAt,activityAt,isCurrent,onLogout,menu?. - Renders: leading icon from
platformmap, title, subtitle ("browser · OS · ip · location"), metalastActivityAt/expiresAt,AppMenu(Log out). - Current device marker:
primaryContainertile + "You" chip. data-*fields: exactly theuser_sessiondoc fields (user-session.schema.ts:15-29).- Flutter:
ListTileinsideAppCard(elevatione-0), icon map fn.
AppActiveBadge (AppBadge) — enumerates session count for Security Hub.
D. API-key artifacts
AppApiKeyCard
| Field | Spec |
|---|---|
| Purpose | One non-revoked key summary |
| Props | prefix, name, scopes[], createdAt, lastUsedAt?, onRevoke |
| Rendering | name titleMedium, prefix**** mono + copy-prefix icon, chips of scopes, footer ${lastUsed ?? 'never used'} · created ${createdAt} in bodySmall |
| a11y | card button + menu; Semantics(label: "API key <name>") |
| Tokens | surfaceContainerLow, R-md, El-e-1 |
AppSecretReveal (one-time key reveal)
| Field | Spec |
|---|---|
| Purpose | Display raw key after create; warning that it's shown once (auth.service.ts:414). |
| Props | secret, copyable, dismissible |
| Rendering | mono body, wrapped card with copy; warning AppBanner(warning) "Copy now – you won't see this again." |
| Copy | Clipboard.setData + Snackbar("Copied"), lightImpact. |
| a11y | Semantics(liveRegion) on copy result; scrolled-into view. |
E. Status & feedback
- AppSecurityBanner — security anniversary: "Change your password" / "Verify email" prompts.
- Reuses
AppBanner,AppSnackbar,AppErrorState,AppEmptyState,AppOfflineBannerfrom 00-shared/03 with no module changes.
F. Only elements the API supports
- Sessions/API keys lists → no items (directly empty) — no infinite scroll needed
(
findByUserIdreturns full arrays,user-session.repository.ts:19,api-key.repository.ts:20).
G. Flutter widget map (module)
| Component | Flutter widgets |
|---|---|
| AppAuthHeader | Wrap + Text in Padding |
| AppPasswordField | TextFormField + IconButton |
| AppTotpInput | custom FormField<int?> over a single TextField 6-digit mask |
| AppSessionCard | Card + ListTile + MenuAnchor |
| AppApiKeyCard | Card + Row + ActionChips |
| AppSecretReveal | Card + SelectableText + Clipboard |
| AppTotpChip | `Badge`` |
| AppSecurityBanner | MaterialBanner |
H. Golden tests (base 00-shared/03 G)
Every module component: golden at phone/tablet/desktop + dark mode; error/all-secret states for AppTotpInput, AppSecretReveal, AppSessionCard.
08 — Form Specifications (Auth Module)
- 1. Login Form —
POST /auth/login(login.dto.ts) - 2. Register Form —
POST /auth/register(register.dto.ts) - 3. Forgot Password —
POST /auth/forgot-password(forgot-password.dto.ts) - 4. Reset Password —
POST /auth/reset-password(reset-password.dto.ts) - 5. 2FA (enable/verify/disable) —
totp-setup.dto.ts - 6. API Key Create —
POST /auth/api-keys(create-api-key.dto.ts) - 7. Verify Email —
verify-email.dto.ts - Form-level rules (all)
- Client-side error priority
Every form field-by-field for the Auth module. Validation mirror class-validator decorators exactly from
src/modules/auth/dto/*.ts; messages follow theVALIDATION_ERROR(400) envelope with per-fielddetails. Passwords havehed server side (Argon2id). Client validates inline then submits; server 400 shadows client.
1. Login Form — POST /auth/login (login.dto.ts)
| # | Field | Label | Hint / Placeholder | Keyboard | Autofill | Validation (server) | Client UX |
|---|---|---|---|---|---|---|---|
| 1 | email | john.doe@springfield.edu | emailAddress | username | @IsEmail() required | trim+lower; error: "Enter a valid email"; TextInputAction.next | |
| 2 | password | Password | type your password | text(default) | current-password | @IsString() + @MinLength(6) required | obscure+reveal; error: Password must be at least 6 characters; onSubmitted submits |
Cta: primary full 48 h, loading replaces label. Server 401 → field-level inline "Invalid
email or password." (both fields). Double-submit blocked (00-shared/08 §6).
2. Register Form — POST /auth/register (register.dto.ts)
Form metadata: firstName(req), lastName(req), email(req), password(req, min8),
tenantId(req), phone(opt).
| # | Field | Type | Autofill | Decorators | Notes/UX |
|---|---|---|---|---|---|
| 3 | firstName | text | given-name | @IsString() req | first on form |
| 4 | lastName | text | family-name | @IsString() req | — |
| 5 | email | username | @IsEmail() req | normalized lowercase by server (user.schema.ts:28) | |
| 6 | phone | tel (optional) | tel | @IsOptional() @IsString() | country-code prefix; no server format check |
| 7 | password | password | new-password | @IsString() @MinLength(8) | min 8; show strength meter (client-only, not server policy) |
| 8 | passwordConfirm | password (client-only) | — | — | must equal password; no server field — local only |
| 9 | tenantId | text | — | @IsString() req | school slug/tenant id; placeholder "e.g. springfield-school" |
Submit → loading → server:
- 201/409: 409
DUPLICATE_RESOURCE→ banner "An account with this email already exists." - success: navigate
/home+ "verify your email" banner (server mints tokens inregister(); navigating to/homeuses them).
Password minimums: register 8, login 6 (MinLength mismatch between
register.dto.ts:19 and login.dto.ts:12 — flag: login accepting 6-char passwords implies
legacy/other-created accounts; display rule is min 8 for new).
3. Forgot Password — POST /auth/forgot-password (forgot-password.dto.ts)
| # | Field | Type | Keyboard | Autofill | Validation |
|---|---|---|---|---|---|
| 10 | email | — | username | @IsEmail() required |
Behavior: submit disabled until valid email; on success a neutral confirmation screen
(identical copy whether the account exists — server returns same message for both, auth.service.ts:256-283). Rate auth 3/min (auth.controller.ts:92-95). On 429 → disable resubmit + countdown.
4. Reset Password — POST /auth/reset-password (reset-password.dto.ts)
| # | Field | Type | Autofill | Validation |
|---|---|---|---|---|
| 11 | token | hidden (from deep-link ?token=) | — | @IsString() required |
| 12 | password | password | new-password | @IsString() @MinLength(8) |
| 13 | passwordConfirm | password | new-password | = password (client) |
Errors: token invalid → banner 400 "Invalid or expired reset token.", button "Request a new
link" (→ forgot). Token expired → banner "Reset token has expired" (server: auth.service.ts:293-296).
Success → "Password reset successfully." (clears sessions) → re-login.
5. 2FA (enable/verify/disable) — totp-setup.dto.ts
One field token (TOTP code): 6 digits (default TOTP_DIGITS=6; env.ts:78), numeric,
8 digits max (min/max from env.ts:78), keyboard number, Regex validation client
^\d{6,8}$, exactly 6 unless org config differs.
6. API Key Create — POST /auth/api-keys (create-api-key.dto.ts)
| Field | Type | Required | Validation | UX |
|---|---|---|---|---|
name | text | ✓ | @IsString() | max length 48 (client), autofocus |
scopes | chips | no | @IsArray() @IsString({each:true}) | free-text input chips; hinted "e.g. report.generate"; no enum validation server-side — free string list |
Submit → returns {id, prefix, key} → swaps form for AppSecretReveal. Done → list refresh.
If 429 → disable button, countdown.
7. Verify Email — verify-email.dto.ts
token @IsString() — presented via deep link or manual paste box "Enter your code".
Form-level rules (all)
- Double-submit: disabled while pending; stop-media-first.
- Optimistic: no optimistic writes for any auth mutation (tokens/credentials are never guessed) — always server-confirm.
- Undo: none applicable to credential/secret actions; revoke has a confirm (14).
- Abandonment: pre-auth config is single-state; clearing email preserves injected token.
- Keyboard:
.nextsequence, last.done;Entersubmits. - Password managers:
autofillHintsset on every auth field (09 §10). - Error copy: from
messageof envelope only for business 4xx; codes for the rest.
Client-side error priority
- 400 VALIDATION → field.
- 401 UNAUTHENTICATED → form message.
- 409 DUPLICATE_RESOURCE → inline.
- 422 BUSINESS_RULE_VIOLATION → banner.
- 429 RATE_LIMITED → countdown.
- 5xx → AppErrorState.
09 — User Behaviour (Auth Module)
- 1. Expected behaviour (server-contract-driven)
- 2. Common mistakes & countermeasures
- 3. Power & new users
- 4. Mobile & a11y users
- 5. Password managers
- 6. Interrupted sessions & retries
- 7. Abandonment
- 8. Frustration points → UX treatments (summary table)
- 9. Cross-device & conflict
- 10. Adoption path (holistic)
Expected behaviour (derived from server semantics + shared interaction rules), the common mistakes users make with these screens, and the UX countermeasures. Purpose: hand the UI designer a playbook of what the user does so the flow never surprises.
1. Expected behaviour (server-contract-driven)
- Login failure never distinguishes unknown email vs wrong password → UI must keep the
message generic to avoid teaching users "accounts exist" (
auth.service.ts 125/131/141). - Refresh is seamless: within the 15 min access TTL the app refreshes quietly; a 401 on
any request triggers single-flight
POST /auth/refresh; a second failure →SessionExpired. - After password reset all sessions die → user is logged out everywhere; from the client the only state is "session invalid"; UI: reason banner "Password was changed on another device" (so it reads as intentional).
- After logout, client clears local tokens, so the refresh never succeeds → login screen.
2. Common mistakes & countermeasures
| Mistake | Related | UX countermeasure |
|---|---|---|
| Typing wrong/` casecaps-lock for password | Login | show error; ensure a visible hint "Caps Lock is on" would help (proposed); reveal toggle; keep team away from password manager-fighting |
| Forgetting which tenant/school id | Register | placeholder + example; hint autofilled from invite |
| Losing / reusing the verify-email token (email link fired twice / link lost) | verify | server returns "already verified" → benign; resend link (rate-limited) |
| Resend obnoxious spam | resend-verification | rate 5/120s, cap UX "Wait 1 min" |
| Tapping "log out all" then leaving the device | logout-all | confirm dialog; after action show "logged out everywhere" result |
| Losing 2FA device & can't get in | 2fa | recovery codes (planned) + clear warning at setup: "save these recovery codes" |
| Revoking a device you're currently on → locked out of that session | sessions | identify current row (badge "This device"); warn on revoke of current |
3. Power & new users
- Power user: global shortcuts
Ctrl+Lfocus login field; fast tabbing; API key creation with pasted name; batch revoke pattern; saves assert favs. - New user (register): walk first-run task list ("Verify email", "Set up 2FA", "Invite staff") after token auto-login.
4. Mobile & a11y users
- One-thumb phone: resty single required CTA, min 48 target.
- TalkBack/VoiceOver: login flow fully walkable; errors announced on focus; TOTP sheet single-field semantics; copy buttons labeled.
- Dynamic type 200%: pre-auth forms reflow (no fixed widths).
- Reduced motion: no shake/animation; error = color+icon+text.
5. Password managers
- Correct
autofillHintseverywhere; serverside: password min 8 BUT login min 6 → managers may propose 8-char passwords that pass register but then get 6-char validation... Actually login is a verifier; managers generate ≥12; UI should accept managers' output. - In-app
Autofill: on web,<input autocomplete>; FlutterTextInputAction.
6. Interrupted sessions & retries
- Suspend/resume: app killed mid-session: secure-storage tokens intact → quiet refresh → home.
- Mid-login interruption (app kill): form state lost; return → fresh.
- Retry after rate limit: countdown; auto-resubmit only when the user re-taps.
- Server 5xx after login: show generic error +
requestId, keep form, suggest retry; tokens not committed.
7. Abandonment
- Where people quit and what to do:
registermid-way → email capture for invite context; UX: one-tap "resume".resettoken not clicked within 1 h → expired banner, one tap re-send.2fa enableafter obtaining secret → incomplete → show "Finish 2FA setup" reminder row on Security hub (client memoized).
8. Frustration points → UX treatments (summary table)
| Frustration | Treatment |
|---|---|
| "Account locked. Try again later." | Show remaining seconds from server lockedUntil (client can't compute; show "after MM:SS" only if known) — else generic + retry timer |
| 429 on login | countdown chip + "no auto-retry" |
| Device access after revoke | confirm; state icon change |
| Unknown key prefix | no "secret" leaks in UI; only prefix shown |
9. Cross-device & conflict
- Concurrent login on two devices → two sessions (
user_sessionsper device). Conflicting change (e.g., reset on one) → other device receives "session expired" on next action. - Revocation of the wallet's current session → immediate force logout of the app
(
sessionExpired).
10. Adoption path (holistic)
P0 release: register→login→verify→password reset→logout. P1: sessions/devices, 2FA,
api-keys. P2 (planned): recovery codes, impersonation, unlock, revoke-others, 2FA challenge
step (IMPLEMENTATION_PLAN.md).
10 — Interaction Specification (Auth Module)
- 1. Phone / touch
- 2. Keyboard / desktop / mouse
- 3. Haptics (mobile)
- 4. Motion language (module extension of 08 §4)
- 5. Micro-interactions catalogue (auth fl)
- 6. Frustration controls (shared 08 §6 + module)
- 7. Special: deep-link & clipboard interactions
- 8. Validation of interaction to server semantics
Module-specific interactions precise for pre-auth forms and security screens, layered on shared 00-shared/08. Covers every input modality on phone/tablet/desktop.
1. Phone / touch
| Gesture | Behaviour | Where |
|---|---|---|
| Tap | submit, reveal password, copy, open/close sheets | all inputs |
| Tap on CTA | submitting-aware disabled re-tap | login/register/reset/enable |
| Long-press | AppMenu on session/api-key rows | sessions, api keys |
| Double-tap | none (no media in auth) | — |
| Swipe left | no destructive swipe (revoke is destructive) — keep confirm | — |
| Pull down | refresh sessions/api-keys | lists only |
| Pinch/drag | none (no media) | — |
| Hardware back | pre-auth: pop to previous auth step; never back into shell; on root → exit | all |
| Edge swipe | drawer (authed shell) — n/a pre-auth | — |
2. Keyboard / desktop / mouse
| Input | Behaviour |
|---|---|
| Tab | email → password → CTA → secondary links; focus ring primary |
| Enter | submits the visible form (login/register/reset/forgot/2fa) |
| Esc | clears inline error / closes sheet / cancels dialog → returns focus to trigger |
| Ctrl/Cmd+K | n/a pre-auth (focus first field instead) recommended |
| Ctrl/Cmd+Enter | submit from form (same as Enter) |
| Arrows | navigate chips in API-key scopes (input) |
| Right-click | context menu (same as long-press: session revoke, key revoke) |
| Hover | tooltip after 500 ms; CTA elevation hinted |
| Focus | visible ring on every control; logical order |
3. Haptics (mobile)
| Event | Haptic | Ref |
|---|---|---|
| Wrong TOTP | mediumImpact | — |
| Login success | lightImpact | — |
| Copy key | selectionClick | — |
| Enable 2FA | lightImpact | — |
| Destroy confirm (revoke) | heavyImpact | — |
| Rule | disabled when system haptics off; none on desktop | 08 §3 |
4. Motion language (module extension of 08 §4)
| Transition | Duration/curve | Used by |
|---|---|---|
| Route push (pre-auth) | m-base slide | login→forgot→register |
| Success→home | m-base fade-up | any unauthenticated success |
| Sheet (key reveal) | m-base bottom slide | api-key |
| Totp box error | shake AnimatedSwitcher (reduced-motion = none) | 2fa |
| Badge change (2FA on) | scale pop m-fast | security hub |
Reduced-motion: only fades + instant; no shake.
5. Micro-interactions catalogue (auth fl)
| Interaction | Detail |
|---|---|
| Submit pressed | button loading-state swap in m-base, double-tap locked |
| Key copied | snackbar "Copied" + toast, tooltip |
| Password reveal | icon toggles, focus stays, no screen shift |
| TOTP | auto-advance AnimatedContainer; paste-tolerant; on 401: red + shake + focus box |
| Device revoked | row fade-out m-base, list re-animates m-entrance stagger |
| Refresh sessions | RefreshIndicator completes with lightImpact |
6. Frustration controls (shared 08 §6 + module)
- All presses respond < 100 ms with
m-fastripple. - Every network action shows pending state.
- Submit buttons disabled while pending — no double-auth.
- Rate-limit → never auto-retry; always surface countdown.
- Errors never flash; persist until corrected or dismissed (persistent banner).
7. Special: deep-link & clipboard interactions
studylyon://verify-email?token=/reset-password?token=→ fills hidden field; auto-submit.- Manual code-paste →
AppTotpInputaccepts paste. - Copy key → share sheet on tablet/desktop (
AppMenu?) — desktop: Ctrl+C onSelectableText.
8. Validation of interaction to server semantics
Interaction spec never contradicts server: e.g. prevents re-enable 2fa vs server 400;
api-key reveal only once → widget caches nothing; reset kills sessions → client force-logout
banner; unexpected-community → "password changed elsewhere".
11 — Design System Mapping (Auth Module)
- 1. Theme recipes (module)
- 2. Widget → token
- 3. Typography mapping
- 4. Iconography
- 5. Motion tokens (values 00-shared/02 §6)
- 6. Contrast & dark parity
- 7. Breakpoint behaviour
Auth module → Material 3 / Flutter / token mapping, extending 00-shared/04. Every color, type, spacing, elevation, radius, icon, motion reference traces to 00-shared/02_Design_Tokens.md.
1. Theme recipes (module)
| Screens | Theme | Exception |
|---|---|---|
| Pre-auth (login/register/verify/reset) | surface, primary | emphasis: brand primaryContainer; input fill surfaceVariant |
| Security screens (authed) | standard shell | destructive = error |
| 2FA QR card | surfaceContainerHigh | dark: QR contrast ensured (inverseSurface) |
2. Widget → token
| Component | M3 / Flutter | Tokens | Elev | Radius |
|---|---|---|---|---|
| Login card | Card | surfaceContainerLow, outlineVariant border | e-1 | r-lg |
| Populated text field | TextFormField | fill surfaceVariant, border outline, focus primary | — | r-md |
CTA Log in | FilledButton | primary/onPrimary | — | r-md |
| Secondary CTA | TextButton | primary, underline on hover | — | — |
| Register CTA | FilledButton | primary | — | r-md |
| Forgot/reset | OutlinedButton | outline/onSurface | — | r-md |
| TOTP boxes | FilledButton-like boxes | box surfaceVariant, focus primaryContainer | — | r-md |
| Error chip | AppBanner/inline | errorContainer/onErrorContainer | — | r-sm |
| Offline banner | AppOfflineBanner | tertiaryContainer | e-1 | — |
| Sessions card | AppCard | surfaceContainerLow | e-1 | r-lg |
| Current-device highlight | AppCard tonal | primaryContainer | e-1 | r-lg |
| API key ring | AppCard | surfaceContainerLow, secret mono | — | r-lg |
| Secret reveal | AppCard | tertiaryContainer bg (warning-kind) | e-2 | r-lg |
| Dialogs/sheets | AppDialog/AppBottomSheet | surfaceContainerHigh | e-4/e-5 | r-lg/r-xl |
| FAB (api-keys) | FloatingActionButton | primaryContainer | e-3 | r-full |
| Snackbar | SnackBar | inverseSurface | e-5 | r-md |
3. Typography mapping
| Token | Screen element |
|---|---|
headlineMedium | screen titles (Welcome/Security hub headings) |
titleLarge | create-key card, dialog titles |
titleMedium | session rows, key rows, section headers |
bodyMedium | labels, helper lines |
bodySmall | "created/last used", disclaimer, hints |
labelLarge | buttons, tabs (Security tabs) |
mono | token/secret/auto-keys sk… prefix, TOTP digits option |
Numerals (timestamps, key counts) use FontFeature.tabularFigures().
4. Iconography
Material Symbols outlined; module map: login/mail, lock, password, fingerprint,
shield, devices, key, content_copy, logout, refresh, warning, check_circle.
5. Motion tokens (values 00-shared/02 §6)
| Purpose | Token | Applied to |
|---|---|---|
| Form submit→result | m-base | swap to success/error |
| Route transitions | m-base | stack push/pop |
| List exit on revoke | m-base | removal |
| Totp error | m-fast+shake | — |
| Badge pop | m-fast | 2FA on/API-key count |
| Hero QR | m-slow | 2FA QR scale-in (forward) |
Rules: reduced-motion substitutes fades/instants (08 §4).
6. Contrast & dark parity
- All text passes 4.5:1; interactive chrome 3:1.
- Dark: chromatic roles TBD by designer (00-shared/02 §1.4); QR on
inverseSurface. - No sole-color status; 2FA off/on uses badge + icon (09 §9).
7. Breakpoint behaviour
| Class | Pre-auth | Security |
|---|---|---|
| phone <600 | single column | lists full width, detail pushed |
| 600–839 | centered card | master-detail |
| ≥1200 | centered + max width 480 | master-detail breaks, hover rings |
12 — API Mapping (Auth Module)
- 0. Module-wide request envelope & client policy
- Screen: Register
- Screen: Verify email
- Screen: Resend verification (in-verify, JWT)
- Screen: Forgot password
- Screen: Reset password
- Screen: 2FA detail
- Screen: Sessions / devices
- Screen: API keys
- Loading / streaming / realtime
- Client-side error mapping table (module)
- Pagination
- Optimistic / undo
Exact wire contract for every screen → endpoint. Base
/api/v1; envelope per 00-shared/07. All endpoints fromsrc/modules/auth/auth.controller.ts; business rules fromauth.service.ts.BearerJWT; global guards:RateLimitGuard→JwtAuthGuard→RbacGuard(app.module.ts:129-133). Public endpoints:register, login, refresh, logout, verify-email, forgot-password, reset-password(@Public()`).
0. Module-wide request envelope & client policy
| Aspect | Contract |
|---|---|
| Base | https://api.<domain>/api/v1 |
| Headers | Authorization: Bearer <accessToken>; x-request-id client-generated; Content-Type: application/json |
| response | {success,message,data,meta?,timestamp,requestId} |
| error | {success,message,error:{code,details?},timestamp,requestId} |
| Tenancy | tenantId from JWT claim; never in body (except register.tenantId bootstrap) |
| Caching | none (auth state) — client caches nothing server-sided |
| Offline | pre-auth writes blocked; reads (sessions) cached last-good |
| Retry | backoff on 5xx/network; no auto-retry on 429 |
Screen: Login / 2FA-challenge
| Endpoint | POST /auth/login |
| Request | {email, password} (login.dto.ts) |
| Success | 200 data:{accessToken,refreshToken} |
| Client flow | validate → header set → persist token pair (secure storage) → navigate home |
| Auth | @Public, rate auth 10/min (auth.controller.ts:38-44) |
| Errors | 400 validation; 401 UNAUTHENTICATED (“Invalid email or password.”); 429 RATE_LIMITED; 5xx |
| 2FA | (planned) client challenge — no server support today (OQ-1) |
| Offline | blocked, banner |
Endpoint source: auth.controller.ts:40, auth.service.ts:123
Screen: Register
| — |
|---|
POST /auth/register |
Request: {firstName,lastName,email,password(≥8),tenantId,phone?} |
200 data:{accessToken,refreshToken} (tokens in register response) |
@Public() rate auth 5/min (auth.controller.ts:30-36) |
Flag: tenantId in body = tenant bootstrap exception (AGENTS.md:38 note) |
Screen: Verify email
| — |
|---|
POST /auth/verify-email {token} → 200 {message:"Email verified successfully."} or 400/404 |
@Public() rate auth 10/min |
Screen: Resend verification (in-verify, JWT)
POST /auth/resend-verification (JWT) → 200 {message:"Verification email resent."}; rate 5/120s.
Screen: Forgot password
| — |
|---|
POST /auth/forgot-password {email} → 200 {message:"If that email exists, a reset link has been sent."} (both branches) |
rate auth 3/min |
Screen: Reset password
| — |
|---|
POST /auth/reset-password {token,password(≥8)} → 200 {message:"Password reset successfully."} |
| 400 invalid/expired token; side-effect: sessions deleted |
rate auth 5/min |
Screen: 2FA detail
| — |
|---|
POST /auth/2fa/enable (JWT) → 200 {secret, qrCodeUri} |
POST /auth/2fa/verify {token} → 200 {message:"2FA enabled successfully."} |
POST /auth/2fa/disable {token} → 200 {message:"2FA disabled successfully."} |
errors: 400 not-enabled / already-enabled / no-secret; 401 invalid code (auth.service.ts:326-384) |
Screen: Sessions / devices
| — |
|---|
GET /auth/sessions (JWT) → 200 data:[sessionDoc…] |
DELETE /auth/sessions/:id (JWT) → 200 {message:"Session revoked."} |
POST /auth/logout-all (JWT) → 200 {message:"Logged out from all devices."} |
POST /auth/logout {refreshToken} (@Public) → 200 {message:"Logged out successfully."} |
Screen: API keys
| — |
|---|
GET /auth/api-keys (JWT) → 200 data:[{id,name,prefix,scopes,createdAt,lastUsedAt}] |
POST /auth/api-keys (JWT) {name,scopes?} → 200 data:{id,prefix,key} (key once) |
DELETE /auth/api-keys/:id (JWT) → 200 {message:"API key revoked."} |
Loading / streaming / realtime
| Screen | Loading | Streaming | Realtime |
|---|---|---|---|
| login | button | — | — |
| register | button | — | — |
| sessions | AppSkeleton rows | — | (planned) WS session-change event |
| api-keys | AppSkeleton | — | — |
| 2FA | spinner | — | — |
Client-side error mapping table (module)
| Screen | code | UI |
|---|---|---|
| login | 401 | 「 credentials incorrect」 |
| login | 429 | countdown |
| verify-email | 400 | “Verify link expired/invalid” + resend |
| reset | 400 | expired-token banner |
| sessions | 404 (revoke id) | treat as removed |
| api-key create | 400/409 | inline; 401→refresh; 5xx generic+requestId |
| any | 401 (during auth action) | silent refresh; fail→sessionExpired |
Pagination
GET /auth/sessions & GET /auth/api-keys are non-paginated arrays (listX returns
full docs, user-session.repository.ts:19, api-key.repository.ts:20); client renders all
(no infinite scroll). Sort: api-keys by createdAt desc server-side; sessions insertion
order — client groups current-first.
Optimistic / undo
Sessions/key listings are read+refresh (Refresh), revoke=server-confirm (no optimistic)
because irreversible; disable 2FA = server-first (code needed). Exception: row “revoke”
shows immediate removal only on server 200.
13 — State Management (Auth Module)
- 1. Session lifecycle (global, claims ownership of the token pair)
- 2. Per-screen Cubits
- 3. State objects (concise)
- 4. Events & actions map (UI → Cubit → API)
- 5. Caching & refresh
- 6. Realtime
- 7. Error states per action
- 8. Testing hooks (
00-shared/06 §6) - 9. Cross-cutting interplay
Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) + the module-wide session lifecycle that every other module depends on. Backed by
AuthRepository(dio) which calls the endpoints in 12_API_Mapping.md.
1. Session lifecycle (global, claims ownership of the token pair)
stateDiagram-v2
[*] --> unauthenticated : no stored tokens
unauthenticated --> authenticating : SubmitLogin | SubmitRegister
authenticating --> authenticated : tokens stored
authenticating --> unauthenticated : 401 (invalid) reason
unauthenticated --> authenticating : restore (stale tokens) -> refresh
authenticated --> refreshing : access 401 / ttl gone
refreshing --> authenticated : refresh 200 (new tokens swapped)
refreshing --> sessionExpired : refresh failed
sessionExpired --> unauthenticated : clear tokens + reason
authenticated --> sessionExpired : logout-all / revoked / password reset on server
unauthenticated --> [*] : app closed
- Owner:
AuthCubit(single instance, injected). Persists tokens influtter_secure_storage(00-shared/11 §11); on boot reads → refresh → if refresh succeeds ⇒ authenticated; else unauthenticated. sessionExpiredreason:tokenReuse,sessionRevoked,passwordChanged,expired(mapped from error path).- Single-flight refresh in dio interceptor (
00-shared/11 §5): concurrent 401s coalesce into onePOST /auth/refresh; swap pair; replay queue.
2. Per-screen Cubits
| Screen | Cubit | Events → State |
|---|---|---|
| Login | LoginCubit | Login(email,pw) → {initial, loading, success(model), error(code)} |
2FA (planned) | TotpCubit | Submit(code) → {idle, verifying, success, error} |
| Register | RegisterCubit | Register(form) → {idle, submitting, success(tokens→AuthCubit), duplicate, error} |
| Verify | VerifyEmailCubit | Verify(token) → {verifying, success, resendable(error/cooldown)} |
| Forgot | ForgotCubit | Submit(email) → {idle, sent, rateLimited(countdown)} |
| Reset | ResetCubit | Submit(token,pw) → {idle, submitting, success, expired)} |
| Sessions | SessionsCubit | Load, Refresh, Revoke(id) → {initial, loading, loaded([session]), empty, error, revoking} |
| ApiKeys | ApiKeysCubit | Load, LoadMore(n/a), Create(form), Revoke(id) → parallel {list, createFlow{sheetState, revealing(secret), done}, error} |
| 2faDetail | TfaDetailCubit | LoadStatus, Enable, Verify(code), Disable(code) → {off, setupReady,on, verifying, disabled} |
| Lock | LockCubit | Authorize(biometric) — device-level, (forward-looking) |
- All prefixed
AuthHub*not needed — AuthCubit + per-screen cubits. - Loading & caching:
LoadStatefrom00-shared/06 §3.1; lists refresh viaRefreshIndicatorbypassing cache; no staleness for auth (server truth).
3. State objects (concise)
class AuthState { AuthStatus status; AuthenticatedUser? user; String? reason; }
class Session { id, deviceName?, browser?, os?, platform?, ip?, location?, expiresAt, lastActivityAt, isCurrent; }
class ApiKeyRef { id, name, prefix, scopes, createdAt, lastUsedAt; }
class ReadyApiKey { ApiKeyRef meta; String secret; } // only in create-sheet scope
class TfaStatus { enabled; }
4. Events & actions map (UI → Cubit → API)
| UI event | Cubit method | Repository call |
|---|---|---|
| LoginScreen submit | login() | authRepo.login(email,pw) |
| VerifyEmailScreen | verify(token) | authRepo.verifyEmail |
| Resend press | resend() | authRepo.resendVerification |
| Forgot Screen | send() | authRepo.forgot |
| Reset Screen | submit() | authRepo.reset |
| Sessions load | load() | authRepo.sessions |
| Sessions row menu | revoke(id) | authRepo.revokeSession |
| LogoutAll dialog confirm | logoutAll() | authRepo.logoutAll |
| ApiKeys list | load() | authRepo.apiKeys |
| FAB create | create(name,scopes) | authRepo.createApiKey() → secret snapshots to sheet revealing |
| key menu | revoke(id) | authRepo.revokeApiKey |
| 2fa card | enable() | authRepo.enable2fa |
| 2fa code | verify(code) | authRepo.verify2fa |
| Turn off | requestDisable(code) | authRepo.disable2fa |
5. Caching & refresh
AuthCubitcache = token pair + user meta (needed everywhere;local_cache).- Sessions/api-keys: no persistence cache → always fetch on screen open;
RefreshIndicatorre-fetch. - Verify/reset: no cache (one-shot).
6. Realtime
- No WS for auth — session changes broadcast to user room via
WsModule(00-shared/07 §8) is(planned); on anySession-relatedpush press navigate or re-fetch with snackbar 「 signed in elsewhere 」 on next app use.
7. Error states per action
| Action | Error | State → |
|---|---|---|
| login | 401 | error(reason AuthError.invalidCredentials) → inline form |
| login | 429 | rateLimited(remaining) → countdown, disable CTA |
| refresh | 401 | SessionExpired(reason) → router /login |
| revoke | 404 | treat-as-removed; error snackbar |
| create-key | 429/4xx | createError(code) → sheet stays, message |
8. Testing hooks (00-shared/06 §6)
- Pure-Dart cubits; unit-test session transition matrix (state diagram above).
- Widget tests: login loading/error/success; sessions empty/error/list; key reveal secret-only-once.
9. Cross-cutting interplay
ConnectivityCubitgates auth submission (offline) → banner + disable CTA.FeatureFlagsCubitnot used (auth module runs pre-feature-gate).- On
logout→AuthCubitclears secure storage then navigates/login(sessionExpirediff server originally revoked).
14 — QA Checklist (Auth Module)
- 1. Functional
- 2. Visual / layout
- 3. Accessibility (00-shared/09)
- 4. Performance
- 5. Offline & network (10 §2)
- 6. Tablet / desktop
- 7. Localization
- 8. Permissions
- 9. Dark mode
- 10. Animations
- 11. Security (session, 2FA, rate, tokens)
- 12. Server-backed edge mirrors
- QA scripts
Module QA specific to framework + shared 00-shared/10. Every check derives from a specific backend behavior so failure analysis is direct.
1. Functional
-
Login with valid email/password returns
data.accessToken/refreshToken(auth.controller.ts:40). -
Wrong password + unknown email → identical 401 message (privacy,
auth.service.ts:125/131). -
Registration (5 fields +
tenantId) returns tokens → app goes straight to authed home. -
Duplicate email register → 409
DUPLICATE_RESOURCEinline. - Verify email 200 / invalid token 400 / already-verified 200.
- Resend throttled (5/120 s).
- Forgot → neutral "reset link sent" screen (same host who exists/missing).
- Reset: min8; revokes all sessions → other devices logged out.
-
Refresh rotation: two sequential
POST /auth/refreshsucceed; old token 401 on reuse. - Logout (one device) leaves other sessions; logout-all clears all.
- Sessions list shows browser/OS/IP/location; revoke endpoint removes row.
- 2FA: enable → verify → on; disable requires code; 400/401 branches.
- API key create: secret shown once; list hides it; revoke removes.
-
Rate limit 429 in production (guard
rate-limit.guard.ts:30).
2. Visual / layout
- Pre-auth centered-form on tablet/desktop; phone one-thumb flow.
- Dark-light parity across login/2FA/keys (no hardcoded colors).
- Long school/tenant id + long key prefixes wrap without clipping at 200% text scale.
3. Accessibility (00-shared/09)
- TalkBack: login+2FA/session journey fully operable without sight.
- VoiceOver separately.
- Keyboard-only desktop: focus order, ring visible.
- Errors announced on focus + live region for snackbars (copied, revoked).
- 2FA digit field group readable.
- Contrast: body 4.5:1, icons 3:1.
- Reduced-motion: shake off.
4. Performance
- Login round-trip < budget; no duplicate parallel requests (refresh single-flight).
- No flicker: skeleton ≤ 200 ms for sessions update.
- Memory stable over 20-min session.
5. Offline & network (10 §2)
- Login offline → banner + disabled.
- Sessions cached last-good + offline banner.
- Revoke offline → blocked (no silent failure).
- Slow network × 429 countdown correct.
6. Tablet / desktop
- Master-detail sessions/api-keys at ≥840 px.
- Hover + right-click menus.
- Table: Enter submits, TAB order.
- focus.
7. Localization
- All strings via i18n; SMS/locales lengths; RTL on security lists.
- Date/time locale for expires/last-used.
8. Permissions
- Self-service auth screens require valid JWT only (no permission metadata — OQ-5).
- Cross-tenant sessions consumption impossible (no other-tenant reads possible by design, but revoke-by-id has no owner check — flag: confirm product decision).
9. Dark mode
-
Full visual pass; key
surfaceContainerHighon dark; contrast.
10. Animations
- Tokens only; reduce-motion; no flashes in key reveal.
11. Security (session, 2FA, rate, tokens)
- Tokens never logged, never in analytics/screenshots.
- Secure storage only.
- Session-expiry overlay (reason banner) — app re-login flow.
- Refresh single-flight and rotation.
-
API-key
reveal= one-time only; clipboard auto-clear after 5s (proposed). - Rate-limit loops — no auto retry on 429.
12. Server-backed edge mirrors
-
Locked account message (server may set
lockedUntil). - Password reset token expiry path (1 h).
-
Register with unknown
tenantIdstill succeeds (creates that tenant) — confirm UX.
QA scripts
- Golden per component (light/dark × phone/tablet/desktop × key states).
- E2E: register→verify→login-2FA→create-key→revoke→logout-all (device-cloud P0).
- Security smoke: reuse rotated refresh → expect 401, then session expiry screen.
15 — Flutter Implementation Guide (Auth Module)
- 1. Folder structure
- 2. Dependencies
- 3. Cubits
- 4. AuthRepository (single)
- 5. Navigation
- 6. Theme
- 7. Extensions
- 8. Localization keys
- 9. Secure token storage
- 10. Testing
- 11. Performance
- 12. Proposals flagged to the team
How to build the Auth feature in the Flutter client on top of 00-shared/11. Forward- looking spec; no client repo exists yet.
1. Folder structure
features/auth/
├── domain/
│ ├── models/
│ │ ├── authenticated_user.dart # id, tenantId, roles[]
│ │ ├── session.dart # user_session mirror
│ │ └── api_key_ref.dart # id, name, prefix, scopes, createdAt, lastUsedAt
│ └── exceptions/auth_auth_exceptions.dart # typed ApiException*
├── data/
│ ├── dto/
│ │ ├── login_dto.dart
│ │ ├── register_dto.dart
│ │ ├── totp_dto.dart
│ │ └── api_key_create_dto.dart
│ └── repositories/
│ └── auth_repository.dart
└── presentation/
├── cubit/
│ ├── auth_cubit.dart
│ ├── login_cubit.dart
│ ├── register_cubit.dart
│ ├── sessions_cubit.dart
│ ├── api_keys_cubit.dart
│ └── tfa_detail_cubit.dart
├── pages/
│ ├── login_page.dart
│ ├── register_page.dart
│ ├── forgot_page.dart
│ ├── reset_page.dart
│ ├── verify_email_page.dart
│ ├── security_hub_page.dart
│ ├── sessions_page.dart
│ ├── api_keys_page.dart
│ ├── api_key_create_sheet.dart
│ └── tfa_detail_page.dart
└── widgets/
├── auth_header.dart
├── totp_input.dart
├── session_card.dart
├── api_key_card.dart
└── secret_reveal.dart
2. Dependencies
flutter_bloc, dio (AppDio with refresh/error interceptors), go_router,
get_it, secure_storage, intl, qr (client-side QR for qrCodeUri, no network).
TOTP verification client-side never (server owns secret) — only input automation.
3. Cubits
AuthCubit: state machine (§13)flutter_secure_storageforaccessToken/refreshToken/tenantId/user. On boot:restore()→refresh().- UI cubits call
AuthRepository; never direct dio.
4. AuthRepository (single)
class AuthRepository {
// throws ApiException(code,status)
Future<TokenPair> login(LoginDto);
Future<TokenPair> register(RegisterDto);
Future<TokenPair> refresh(String refreshToken);
Future<void> logout(String refreshToken);
Future<void> logoutAll();
Future<void> verifyEmail(String token);
Future<void> resendVerification();
Future<void> forgotPassword(String email);
Future<void> resetPassword(String token, String password);
Future<TotpSetup> enable2fa();
Future<void> verify2fa(String code);
Future<void> disable2fa(String code);
Future<List<Session>> sessions();
Future<void> revokeSession(String id);
Future<List<ApiKeyRef>> apiKeys();
Future<CreatedApiKey> createApiKey(String name, List<String> scopes);
Future<void> revokeApiKey(String id);
}
All through AppDio; the refresh interceptor is global (not here).
5. Navigation
- go_router
GoRoutefor/login,/register,/verify-email,/forgot-password,/reset-password,/settings/security. - Route guards read
AuthCubit; pre-auth stack = simple list; redirect rule:state.redirect→ authed user →/home, unauthed → public. - Deep link
Uri→ token params passe to Verify/Reset pages.
6. Theme
AppTheme.light()/dark()unchanged; pre-auth pages wrap inScaffold(primary: true)? No — reuse global theme; auth adds no tokens (11_Design_System_Mapping.md).
7. Extensions
String.maskedEmail()for welcome/footer.DateTime.toRelative()for "Last used 3 h ago".Session.platformIcon()mapping.
8. Localization keys
auth.login.*… Full list in 08_Form_Specifications.md; all server messages mapped to keys,
fallback to message for business 4xx only.
9. Secure token storage
flutter_secure_storage: keysauth.access,auth.refresh,auth.user,auth.expiry.- No direct
SharedPreferences(that would be a leak vector).
10. Testing
- Unit:
AuthCubitstate transition matrix;LoginCubitform validation; mapper DTO→model. - Widget: login states (idle/loading/error/rate/offline);
TotpInputpaste/advance; sessions empty/loaded; key reveal one-time. - Golden: components + pages light/dark × 3 sizes (
00-shared/10 §9). - Integration: register (with mock server) → home; expired refresh → sessionExpired → login; revoke-all → login.
- E2E (P0): register → verify → enable 2FA → create key → revoke → logout on device cloud.
11. Performance
- List builders for sessions/keys; no rebuild of whole page on CTA;
constconstructors; QR image lazy-build off-screen; clipboard micro-delay.
12. Proposals flagged to the team
- When server adds first-login-verify gate + 2FA challenge, enable
LoginCubitchallenge state (OQ-1). - When
lockout/family-revoke/recovery-codesland (IMPLEMENTATION_PLAN.md), add the associated screens. - Analytics wiring waits share
99(AnalyticsServiceinterface).
01 — Product Overview (Biometric Module)
- 1. Purpose
- 2. Business goals
- 3. User goals
- 4. Stakeholders
- 5. Why this exists
- 6. Dependencies
- 7. Success metrics
- 8. Edge cases
- 9. Assumptions (module)
- 10. Open questions (module-level; global ledger in 00-shared/12)
- 11. Glossary (this module)
StudyLyon — multi-tenant ERP / School Management API. This package designs the Biometric module client (Flutter admin console, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, queues, and permissions are derived directly from
src/modules/biometric/**,src/modules/attendance/**,src/infrastructure/bullmq/**,src/modules/rbac/permissions.constants.ts, and the authoritative blueprint (studylyon-blueprint/04-Modules/Biometric.md). No feature is invented; gaps are flagged in Assumptions & Open Questions.
1. Purpose
Biometric is StudyLyon's device-integration layer for attendance capture
(studylyon-blueprint/04-Modules/Biometric.md:3). Hardware fingerprint/QR devices at the
school gate produce raw punches; the Biometric module ingests them as immutable raw
events (biometric_logs) and the Attendance module derives queryable attendance records
from them (RELATIONSHIPS.md:95-103).
Today the backend implements only the ingest half of that vision: one endpoint
(POST /biometric/ingest) that persists a raw log. Everything else — device
registration, sync, enrollment, verification, attendance derivation — is (planned)
per the blueprint.
| Responsibility | Status | Source |
|---|---|---|
| Ingest raw punch logs (immutable) | ✅ real | biometric.controller.ts:14-18 |
Store device registry (biometric_devices) | ✅ schema + repo (no CRUD API yet) | biometric-device.schema.ts:13-32 |
| Register / manage biometric devices | 🚧 planned | blueprint 04-Modules/Biometric.md:13 |
Poll / sync device punches (biometric-sync queue) | 🚧 queue + scheduler exist; worker stub | queue.constants.ts:7, scheduler.service.ts:70-76 |
| Map employee codes → student/staff | 🚧 planned (employeeCode field) | blueprint COLLECTIONS.md:1878-1879 |
Trigger attendance derivation (BiometricImported) | 🚧 planned event | blueprint EVENTS.md:18 |
| Template enrollment / verify / match | 🚧 planned — no schema, no endpoint | blueprint Biometric.md:13-19 |
| Verify device health | 🚧 planned | blueprint Biometric.md:19 |
2. Business goals
| Goal | Measure |
|---|---|
| Attendance marking latency < 1 s (PRD) | PRODUCT_REQUIREMENTS_DOCUMENT.md:136 |
| Zero human double-entry at the gate | raw punch → derived attendance, no clerk retyping |
| Audit-grade attendance evidence | logs are insert-only, never updated (biometric-log.schema.ts:7, blueprint COLLECTIONS.md:1866) |
| Immutable forensic record | raw payload preserved for replay (rawData, biometric-log.schema.ts:21-22) |
| Operational resilience | sync failures retried via BullMQ; exhausted → DLQ (blueprint Biometric.md:58) |
| 1-year retention bound | biometric_logs TTL + archive (blueprint DATA_RETENTION.md:25) |
3. User goals
- School admin / ops: add a gate device once, see sync status, resolve offline devices, trust that every punch lands in attendance.
- Office clerk (device operator): enroll a student's fingerprint on a device, watch the enrollment confirm, verify a disputed check-in.
- Teacher: know a student scanned in before class starts (biometric-fed attendance).
- Parent
(planned): see the child's biometric check-in silently reflected in the daily attendance record. - Platform admin: cross-tenant device fleet health, vendor-SDK risk tracking.
4. Stakeholders
Institution admins, office clerks, teachers, students/parents (passive), device-vendor support, platform operator, audit/compliance board, QA + design + engineering.
5. Why this exists
School gate attendance is the highest-frequency, highest-trust data point in a school day.
Manual marking is error-prone and unverifiable; biometric devices make the punch
tamper-evident. The PRD makes device capture a first-class requirement (FR-ATT-02,
PRODUCT_REQUIREMENTS_DOCUMENT.md:87) and the acceptance criteria demand that "biometric
capture maps to a verified student record" (ACCEPTANCE_CRITERIA.md:39).
6. Dependencies
| Dependency | Role | Source |
|---|---|---|
| Attendance module | consumes BiometricImported → derived records; AttendanceSource.BIOMETRIC | blueprint Biometric.md:47; attendance.schema.ts:18 |
| Students module | studentId ref on every log (ref: 'Student') | biometric-log.schema.ts:9-10 |
BullMQ biometric-sync | periodic device polling (*/15 * * * *) | queue.constants.ts:7; scheduler.service.ts:70-76 |
BullMQ attendance-process | attendance derivation worker | event-queue-map.ts:14-17; attendance.worker.ts:16 |
Integrations module (planned) | encrypted device credentials/config | blueprint Biometric.md:48,59 |
Audit module (planned) | immutable log-write trail | blueprint Biometric.md:49 |
| RBAC | biometric.log.create, biometric.log.read, biometric.device.manage | permissions.constants.ts:41-43 |
| Mongo collections | biometric_logs, biometric_devices | biometric.module.ts:21-24 |
7. Success metrics
- Ingest P95 < 1 s; zero log drops on the happy path.
- Punch → attendance record derived < 2 min (sync cadence 15 min)
(planned). - ≥ 99% of daily punches reconciled against attendance.
- Zero cross-tenant log leakage (tenant-scoped repos,
BaseRepository). - Offline device detected and flagged within one sync cycle
(planned).
8. Edge cases
- Duplicate punch: two logs for the same student/second; logs are immutable — dedupe
is the derivation layer's job (blueprint keeps raw punches;
processedflag planned,COLLECTIONS.md:1888). - Unknown deviceId:
ingestaccepts any string — no device registry check today (create-biometric-log.dto.ts:9-11). - Unknown studentId:
@IsMongoIdonly checks shape, not existence (create-biometric-log.dto.ts:5-7); orphan log persists. - Tenancy:
tenantIdis taken from the JWT claim byBaseRepository— butingestnever runs tenant logic of its own (see OQ-1). rawDatagap: schema storesrawData(biometric-log.schema.ts:21-22) but the DTO does not accept it (create-biometric-log.dto.ts:4-20) — the forensic payload cannot actually be ingested (OQ-2).- Employee-code mapping: blueprint field
employeeCode(COLLECTIONS.md:1878-1879) is absent from the implemented schema — mapping punches to people is done by the caller today (device pushesstudentId). as any:biometric.service.ts:15casts the DTO withas any— strict-TSno-explicit-anyis currentlyoffin ESLint (AGENTS.md hard rule flags this).
9. Assumptions (module)
- PRD scope excludes native mobile apps — web-first (
PRODUCT_REQUIREMENTS_DOCUMENT.md:144). The Flutter client in this package is therefore a forward-looking admin console, not the capture surface. Capture happens on vendor hardware + SDK, which is exactly why the roadmap is gated: "Biometric depends on hardware vendor SDK availability" (FEATURE_ROADMAP.md:56); device integration is unchecked on the roadmap (:26). - No template storage exists anywhere. The implemented module stores no
fingerprints, hashes, or templates — only punch metadata (
studentId, deviceId, timestamp). Enrollment/verify/match are(planned)and their storage format is an open product decision (OQ-3). - The
biometric-syncworker is a stub. The queue is real (queue.constants.ts:7), registered (bullmq.module.ts:34), and scheduled (scheduler.service.ts:70-76), but no@Processor('biometric-sync')exists — jobs are enqueued but never processed (OQ-4). - No RBAC metadata on the ingest endpoint — any authenticated user may call it
(no
@Permissions(...), OQ-5). AttendanceSource.BIOMETRICexists but nothing sets it (attendance.schema.ts:18); no code path links a biometric log to amark()call.- Coaching mode: blueprint plans session-based (not daily) check-in for coaching
tenants (
IMPLEMENTATION_PLAN.md:777). - Permissions: blueprint names
biometric.read/biometric.sync(Biometric.md:66-68); the implemented constants instead declarebiometric.log.readand no sync perm (permissions.constants.ts:41-43).
10. Open questions (module-level; global ledger in 00-shared/12)
| # | Item | Impact |
|---|---|---|
| OQ-1 | ingest casts dto as any and never reads TenantContextService; tenant scoping happens implicitly in logRepo.create. Is an explicit tenant guard needed? | Log integrity, cross-tenant audits |
| OQ-2 | rawData is in the schema but not the DTO — ingest the forensic payload now or drop the field? | Forensic/replay promise (Biometric.md:56) |
| OQ-3 | Template storage format (hash? encrypted blob? vendor raw?) — no schema exists. | Entire enrollment/verify UX + privacy posture |
| OQ-4 | Who writes the biometric-sync worker? Jobs currently stall. | Sync status UX is speculative until it lands |
| OQ-5 | POST /biometric/ingest has no permission metadata — device webhooks may need a machine-token path, not user JWTs. | Auth model for devices |
| OQ-6 | Duplicate-punch / dedupe policy: derive at ingest or in attendance? | Attendance correctness |
11. Glossary (this module)
| Term | Meaning |
|---|---|
| Punch | one raw device event (biometric_logs row) |
| Log | immutable raw event: {studentId, deviceId, timestamp, mode?, rawData?} |
| Device | biometric_devices doc: {name, deviceId, model?, status, location?, config?}; status ∈ active/inactive/offline (biometric-device.schema.ts:7-11) |
| Sync | periodic device poll via biometric-sync queue (*/15 * * * *) |
| Template | the enrolled biometric sample — planned, format undecided (OQ-3) |
| Attendance derivation | raw logs → attendance docs via BiometricImported event (planned) |
02 — User Personas (Biometric Module)
- 1. Ops Manager / School Admin — "Riya"
- 2. Office Clerk / Device Operator — "Arjun"
- 3. IT / Technical Admin — "Dev"
- 4. Teacher — "Meera"
- 5. Platform Admin (cross-tenant) — "Sam"
- 6. Parent — passive consumer
- Permission → persona matrix (real today)
- Persona acceptance notes
Five operational personas + one passive consumer. Permission bounds from
permissions.constants.ts:41-43(biometric.log.create,biometric.log.read,biometric.device.manage). Only the clerk persona maps 1:1 to a today-implementable surface (log read); device management and enrollment screens are(planned).
1. Ops Manager / School Admin — "Riya"
| Aspect | Detail |
|---|---|
| Role | Senior administrator; owns daily operations |
| Context | Desktop + phone; busy between 7:00-9:00 AM (gate rush) |
| Goals | Gate works with zero babysitting; disputes resolved before parent asks |
| Pains | Device offline for 3 days unnoticed; manual re-entry when sync breaks |
| Permissions | biometric.device.manage, biometric.log.read, biometric.log.create (all three) |
| Surfaces | Device list, sync status, log audit (planned screens) |
| Key metric | "Did every student who entered get counted?" |
| Behaviour | Checks device fleet once daily; flags offline devices immediately |
2. Office Clerk / Device Operator — "Arjun"
| Aspect | Detail |
|---|---|
| Role | Front-office staff who physically operates the gate device |
| Context | Stands next to the hardware; runs the admin console on a tablet/kiosk |
| Goals | Enroll new students fast; re-enroll on scanner rejection; confirm a student "actually scanned" |
| Pains | Fingerprint rejected on dry hands; duplicate enrollment mistakes; unclear enrollment status |
| Permissions | biometric.device.manage, biometric.log.read |
| Surfaces | Enrollment wizard (planned), device detail, punch timeline, log search |
| Behaviour | High-frequency short tasks; one-handed; hates multi-step dialogs |
3. IT / Technical Admin — "Dev"
| Aspect | Detail |
|---|---|
| Role | Manages device fleet, network, vendor SDK integration |
| Context | Desktop; SSH-level comfort; reads logs |
| Goals | Diagnose sync failures; push config to devices; watch the biometric-sync queue |
| Pains | No worker behind biometric-sync today (jobs stall — queue.constants.ts:7, no @Processor); vendor SDK availability gates everything (FEATURE_ROADMAP.md:56) |
| Permissions | biometric.device.manage, biometric.log.read |
| Surfaces | Device config, sync job history, Bull-Board queue view |
| Behaviour | Batch operations; export-friendly; tolerates dense technical UI |
4. Teacher — "Meera"
| Aspect | Detail |
|---|---|
| Role | Takes class attendance; checks "who's in" at start |
| Context | Phone, 2 minutes before period |
| Goals | See biometric check-ins merged into the class attendance view without re-counting |
| Pains | If derivation isn't wired, she still marks manually and trusts nothing |
| Permissions | attendance.mark (+ read via attendance endpoints); no biometric permission today |
| Surfaces | Class attendance list (attendance/class/:classId); biometric contribution is passive |
| Behaviour | Only cares about the derived result, not the device |
5. Platform Admin (cross-tenant) — "Sam"
| Aspect | Detail |
|---|---|
| Role | StudyLyon operator; sees fleet across tenants |
| Context | Desktop; support tickets |
| Goals | Fleet health; spot tenants with dead devices; vendor-SDK risk tracking |
| Pains | No cross-tenant biometric view exists; roadmap gates the whole feature |
| Surfaces | (planned) platform dashboard tile |
| Behaviour | Weekly health review |
6. Parent — passive consumer
| Aspect | Detail |
|---|---|
| Role | Wants "reached school" confidence |
| Context | Push notification (forward-looking) |
| Goals | Check-in reflected in daily attendance; late-punch alerts (proposed) |
| Pains | Device misread → child marked absent → angry call to office |
| Surfaces | Attendance record only (attendance/student/:studentId) |
| Behaviour | Reads, never administers |
Permission → persona matrix (real today)
| Permission | Source | Riya | Arjun | Dev | Meera | Sam |
|---|---|---|---|---|---|---|
biometric.log.create | permissions.constants.ts:41 | ✅ | — | — | — | — |
biometric.log.read | permissions.constants.ts:42 | ✅ | ✅ | ✅ | — | — |
biometric.device.manage | permissions.constants.ts:43 | ✅ | ✅ | ✅ | — | — |
biometric.sync (blueprint name) | blueprint Biometric.md:67 | — | — | ✅ | — | — |
Persona acceptance notes
- Only Riya/Arjun/Dev can touch biometric screens; Meera and parents see derived attendance only — keep biometric internals out of their navigation.
- Arjun's enrollment flow must be ≤ 3 taps per student or it won't be used at the gate.
03 — User Journey (Biometric Module)
- 1. Device Setup (Ops Manager) —
(planned) - 2. Enrollment (Clerk) —
(planned) - 3. Daily Gate Flow (real ingest + planned pipeline)
- 4. Verify a Disputed Check-in (Clerk) —
(planned) - 5. Sync Failure / Offline Device (IT Admin) —
(planned) - 6. Retention / Purge (Platform Admin)
- Journey → screen map
Journeys trace the module's life cycle: device setup → sync → punch ingestion → attendance derivation → dispute. Real steps are quoted with sources; everything after ingestion is
(planned)per the blueprint. Journeys for the admin console (Flutter, forward-looking) and the server pipeline are both captured.
1. Device Setup (Ops Manager) — (planned)
- Admin opens Biometric → Devices (
GET /biometric/devices, blueprintBiometric.md:25). - Taps Register device → form (
POST /biometric/devices, blueprint:26): name,deviceId, model, location, status. - Server enforces unique
(tenantId, deviceId)(biometric-device.schema.ts:36). - Device appears
active(DeviceStatus.ACTIVEdefault,biometric-device.schema.ts:24-25). - Admin tests one punch → finds it in logs next sync.
Today: no device API exists; the schema + repo are ready (
biometric-device.schema.ts,repositories/biometric-device.repository.ts). UI must not ship until the endpoints do.
2. Enrollment (Clerk) — (planned)
- Clerk opens Devices → [device] → Enroll.
- Selects student (search by name/roll).
- Device captures template → clerk confirms (≤ 3 taps — persona Arjun).
- Server stores template +
employeeCodemapping (blueprintCOLLECTIONS.md:1878-1879). - Success screen: student name + "enrolled" chip.
Today: no template storage, no enrollment endpoint (OQ-3 in 01). Blocked on vendor SDK + storage-format decision. Also blocked on acceptance criterion "biometric capture maps to a verified student record" (
ACCEPTANCE_CRITERIA.md:39).
3. Daily Gate Flow (real ingest + planned pipeline)
07:50 student scans at gate device
↓
device records punch (employeeCode, direction, rawPayload) [blueprint COLLECTIONS.md:1872-1892]
↓
punch polled by biometric-sync job (queue BIOMETRIC_SYNC, */15 * * * *)
↓ (queue.constants.ts:7, scheduler.service.ts:70-76 — worker missing today)
POST /api/v1/biometric/ingest {studentId, deviceId, timestamp, mode?}
↓ (biometric.controller.ts:14-18 — REAL today)
persisted as immutable biometric_logs row
↓ (biometric.service.ts:14-16 → biometric-log.schema.ts:7-27)
BiometricImported event → attendance-process queue (planned, EVENTS.md:18)
↓
attendance.mark(source: BIOMETRIC) → AttendanceSource.BIOMETRIC (planned; enum exists, attendance.schema.ts:18)
↓
parent/teacher sees check-in in attendance views
Today's actual flow stops after the ingest row is persisted — no event, no derivation, no parent visibility. The ingest endpoint is real; everything below the fold is not.
4. Verify a Disputed Check-in (Clerk) — (planned)
- Parent questions "absent" → clerk opens Biometric → Logs.
- Filters by student + date (
GET /biometric/logs, blueprintBiometric.md:30). - Opens punch → sees
timestamp,deviceId,mode, raw payload (biometric-log.schema.ts:12-22). - Confirms the punch exists → edits attendance via
PATCH /attendance/:id(attendance.controller.ts:54-59) withsourcepreserved; or finds no punch → evidence stands.
Immutability is the trust mechanism: logs "never update" (blueprint
COLLECTIONS.md:1866,Biometric.md:55) — the dispute path reads, never mutates.
5. Sync Failure / Offline Device (IT Admin) — (planned)
- Device
statusflipsoffline(enumbiometric-device.schema.ts:7-11; health-check planned, blueprintBiometric.md:19). - Sync job
retry: 4, exponential 2000 ms(blueprintRETRIES.md:38); exhausted → DLQ (blueprintBiometric.md:58). - Admin sees failed job in Bull-Board; resolves network; taps Sync now
(
POST /biometric/devices/:id/sync, blueprintBiometric.md:28). - Caught-up punches land as logs with their original
timestamp— derivation stays correct (COLLECTIONS.md:1883).
6. Retention / Purge (Platform Admin)
biometric_logs aged > 1 year → TTL + archive (blueprint DATA_RETENTION.md:25);
tenant purge reuses the existing TENANT_PURGE queue (queue.constants.ts:12).
Journey → screen map
| Journey | Screen (05) | Status |
|---|---|---|
| 1 Device setup | Device List / Device Register | (planned) |
| 2 Enrollment | Enrollment | (planned) |
| 3 Daily gate | Sync Status + Attendance-Verification Check-in | ingest real, rest (planned) |
| 4 Dispute | Biometric Logs | read (planned) |
| 5 Offline device | Device Detail / Sync Status | (planned) |
04 — Information Architecture (Biometric Module)
- 1. Module subtree
- 2. Navigation rules
- 3. Content model (per screen)
- 4. IA decisions
- 5. Future expansion
Navigation hierarchy for the Biometric admin console. Gating by the three real permissions (
permissions.constants.ts:41-43); blueprint-only surfaces are(planned). Global IA in 00-shared/05; this file adds the module subtree only.
1. Module subtree
Settings / Administration
└── Biometric (hub — gated: any of the 3 biometric.* perms)
├── Overview / Sync Status [planned] gated: biometric.device.manage
├── Devices [planned] gated: biometric.device.manage
│ ├── Device List
│ ├── Device Register
│ └── Device Detail
│ ├── Sync now
│ └── Enrollment ──────────── (planned, per-device)
├── Logs [planned] gated: biometric.log.read
│ ├── Log List (search/filter)
│ └── Log Detail (bottom sheet)
└── Attendance-Verification Check-in [planned] gated: biometric.log.read
└── punch timeline / verify result
Routing convention (matches module rest: /biometric/... — controller prefix
@Controller('biometric'), biometric.controller.ts:10):
| Route | Screen |
|---|---|
/biometric | Hub (sync status) |
/biometric/devices | Device List |
/biometric/devices/new | Device Register |
/biometric/devices/:id | Device Detail |
/biometric/devices/:id/enroll | Enrollment (per-device) |
/biometric/logs | Log List |
/biometric/logs?student=:id | Log List pre-filtered by student |
/biometric/verify | Attendance-Verification Check-in |
2. Navigation rules
- Hub-first: Biometric is a single hub row in Settings; no biometric surface may be linked from student/attendance detail views into the admin console (Meera/parent personas must only see derived attendance).
- Permission gating: hub visibility = any
biometric.*perm; Devices subtree =biometric.device.manage; Logs subtree =biometric.log.read(permissions.constants.ts:41-43). - Deep links:
/biometric/logs?student=:idshared by the clerk for dispute resolution (journey 4 in03). - Master-detail (tablet/desktop): Devices and Logs use two-pane list/detail
(
00-shared/05).
3. Content model (per screen)
| Screen | Primary entity | Fields shown | Source |
|---|---|---|---|
| Device List | BiometricDevice | name, deviceId, model, status, location | biometric-device.schema.ts:15-31 |
| Device Register | BiometricDevice (create) | name*, deviceId*, model?, location?, config? | schema + blueprint Biometric.md:26 |
| Sync Status | queue/job state | last sync, pending, failed (→ DLQ) | queue.constants.ts:7, blueprint RETRIES.md:38 |
| Log List | BiometricLog | student, device, timestamp, mode | biometric-log.schema.ts:9-19 |
| Log Detail | BiometricLog + raw | rawData (forensic) | biometric-log.schema.ts:21-22 |
| Enrollment | student + device + template | student ref, deviceId, employeeCode, template status | blueprint COLLECTIONS.md:1878-1879 |
| Verify Check-in | student + punches | punch list, matched attendance status | attendance.schema.ts:16-21 |
4. IA decisions
- Logs and Devices are siblings — one is evidence, the other is infrastructure; mixing them confuses the clerk (Arjun) who only ever touches Logs for disputes.
- Enrollment lives under Device, not under Student — enrollment is a device-scoped operation (template lives on hardware), even though the subject is a student.
- Sync Status is the hub landing — the ops manager's first question is "is the gate
healthy?" (
02, persona Riya); device list + queue health answer it in one glance. - No biometric entity is exposed to student/parent profiles in this module's IA —
biometric data stays admin-side (privacy posture,
14§8).
5. Future expansion
(forward-looking)push/QR check-in (phone-as-badge) — new capture surface under the same Sync Status hub; no IA rework needed.(proposed)fleet analytics tile (platform admin) — read-only dashboard widget, gatedreport.read/platform role.
05 — Screen Inventory (Biometric Module)
- Legend
- 1. Biometric Hub / Sync Status (
/biometric)(planned) - 2. Device List (
/biometric/devices)(planned) - 3. Device Register (
/biometric/devices/new)(planned) - 4. Device Detail (
/biometric/devices/:id)(planned— sync action part of it)` - 5. Enrollment (
/biometric/devices/:id/enroll)(planned) - 6. Biometric Log List (
/biometric/logs)(planned — read path has no endpoint yet) - 7. Biometric Log Detail (bottom sheet)
(planned) - 8. Attendance-Verification Check-in (
/biometric/verify)(planned) - 9. QR/Push Check-in
(forward-looking) - Shared components used
- Analytics events (proposed)
- Keyboard, landscape, tablet, desktop
Every screen of the Biometric module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics. Screens marked
(planned)have no backend endpoint yet — inventory is design-forward.
Legend
States = idle / loading / success / empty / error(offline, sync, queue) / disabled / permission.
Analytics events follow {module}.{screen}.{action} (proposed; SDK open — 00-shared/10 §8).
1. Biometric Hub / Sync Status (/biometric) (planned)
| Field | Detail |
|---|---|
| Purpose | Fleet health at a glance; the ops manager's landing (persona Riya) |
| Entry | Settings → Biometric |
| Exit | → device list, → logs, → verify check-in |
| Widgets | status summary card (devices: active/offline counts), last-sync row, failed-sync banner, queue depth chip (Bull-Board link for Dev) |
| States | loading skeleton; empty ("No devices — register one"); error (queue down); offline |
| Source | planned: GET /biometric/devices + queue health; no real API today |
| Analytics | biometric.hub.open, biometric.hub.sync_status (proposed) |
| Permission | any biometric.* (hub visibility) |
2. Device List (/biometric/devices) (planned)
| Field | Detail |
|---|---|
| Purpose | Manage all devices: register, open, disable |
| Entry | hub → Devices |
| Exit | → /biometric/devices/new; → /biometric/devices/:id |
| Source | planned GET /biometric/devices; unique key (tenantId, deviceId) (biometric-device.schema.ts:36) |
| Row | AppListTile: name, subtitle model · location, trailing status badge (DeviceStatus: active/inactive/offline, biometric-device.schema.ts:7-11) |
| Pull-to-refresh | RefreshIndicator |
| Empty | AppEmptyState "No devices registered" |
| CTA | AppFAB "Register device" |
| Row menu | AppMenu → Detail / Disable (confirm) |
| Permission | biometric.device.manage (permissions.constants.ts:43) |
3. Device Register (/biometric/devices/new) (planned)
| Field | Detail |
|---|---|
| Purpose | Add a gate device to the tenant fleet |
| Form | name*, deviceId*, model?, location?, config? (schema fields biometric-device.schema.ts:15-31) |
| Source | planned POST /biometric/devices (blueprint Biometric.md:26) |
| Primary CTA | "Register device" |
| States | loading; 409 duplicate deviceId inline; success → detail |
| Permission | biometric.device.manage |
4. Device Detail (/biometric/devices/:id) (planned — sync action part of it)`
| Field | Detail |
|---|---|
| Purpose | One device: config, status, sync, enrollment entry |
| Header | name + status badge + model/location; config collapsible (raw, biometric-device.schema.ts:30-31) |
| Actions | Sync now (POST /biometric/devices/:id/sync, blueprint Biometric.md:28), Enroll → /enroll, Disable (confirm), Edit (PATCH /biometric/devices/:id, blueprint :27) |
| Sync states | idle → running (spinner) → success (punch count) / failed (retry; 4× exp backoff, blueprint RETRIES.md:38 → DLQ) |
| Punch preview | last N logs for this device (biometric-log.schema.ts:26 index tenantId+deviceId+timestamp) |
| Permission | biometric.device.manage; punch preview needs biometric.log.read |
5. Enrollment (/biometric/devices/:id/enroll) (planned)
| Field | Detail |
|---|---|
| Purpose | Capture a student's template on a device (clerk, persona Arjun) |
| Steps | select student → device capture → confirm → success chip |
| Source | blueprint: employeeCode mapping (COLLECTIONS.md:1878-1879); no endpoint/schema/template format today (OQ-3) |
| States | per-step loading; capture-failed (re-try, dry-hands hint); duplicate-enrollment warning |
| Exit | success → device detail ("Enrolled: {name}") |
| Permission | biometric.device.manage |
| Blockers | vendor SDK (FEATURE_ROADMAP.md:56); template storage decision; "maps to verified student record" (ACCEPTANCE_CRITERIA.md:39) |
6. Biometric Log List (/biometric/logs) (planned — read path has no endpoint yet)
| Field | Detail |
|---|---|
| Purpose | Evidence trail: search punches by student/device/date; dispute resolution (journey 4) |
| Source | planned GET /biometric/logs (blueprint Biometric.md:30); log fields biometric-log.schema.ts:9-22 |
| Filters | student (deep-link ?student=:id), device, date range, mode |
| Row | AppListTile: student name, deviceId · mode, timestamp |
| Empty | AppEmptyState "No punches found" |
| Row tap | → Log Detail sheet |
| Permission | biometric.log.read (permissions.constants.ts:42) |
7. Biometric Log Detail (bottom sheet) (planned)
| Field | Detail |
|---|---|
| Purpose | Full immutable record incl. forensic payload |
| Content | studentId, deviceId, timestamp, mode, rawData (raw JSON), createdAt; "immutable — never updated" note (blueprint COLLECTIONS.md:1866) |
| Action | "Open attendance record" → student attendance list (GET /attendance/student/:studentId, attendance.controller.ts:37-43) |
| Permission | biometric.log.read |
8. Attendance-Verification Check-in (/biometric/verify) (planned)
| Field | Detail |
|---|---|
| Purpose | Prove a student checked in (dispute) or preview today's biometric-fed marks |
| Input | student search → date |
| Content | punch timeline (logs) + derived attendance status per day; flags AttendanceSource.BIOMETRIC rows (attendance.schema.ts:18) |
| States | no punches (evidence absent), matched (punch → attendance present), mismatched (punch without derived record — derivation gap) |
| Exit | → attendance record edit (PATCH /attendance/:id) |
| Permission | biometric.log.read (+ attendance.edit for the edit hop) |
9. QR/Push Check-in (forward-looking)
Phone-as-badge capture surface under the same hub; no server support; keep route dormant.
Shared components used
AppCard, AppListTile, AppButton, AppSnackbar, AppDialog, AppMenu, AppFAB,
AppSkeleton, AppEmptyState, AppOfflineBanner, AppBadge, AppChips, AppAvatar,
AppRefreshIndicator. Module-specific: DeviceStatusBadge, SyncStatusTile, PunchRow,
PunchTimeline, EnrollStepBar — defined in 07_Component_Library.md.
Analytics events (proposed)
biometric.hub.{open,sync_status}, biometric.device.{list,register,open,sync,disable},
biometric.enroll.{start,capture_ok,capture_fail,complete},
biometric.log.{search,open}, biometric.verify.{matched,mismatch,no_punch}.
Keyboard, landscape, tablet, desktop
- Forms (register/enroll): portrait-first, keyboard avoidance.
- Devices/Logs: two-pane master-detail ≥ 840 px; desktop hover row highlight.
- Sync Status: single column up to 600 dp; three-column tiles ≥ 1200 px.
06 — Screen Specifications (Biometric Module)
- 1. Biometric Hub / Sync Status (
/biometric) - 2. Device List (
/biometric/devices) - 3. Device Register (
/biometric/devices/new) - 4. Device Detail (
/biometric/devices/:id) - 5. Enrollment (
/biometric/devices/:id/enroll) - 6. Biometric Log List (
/biometric/logs) - 7. Biometric Log Detail (bottom sheet)
- 8. Attendance-Verification Check-in (
/biometric/verify) - 9. Motion & state cross-cutting (all screens)
- 10. Implementation status matrix
Production-grade specs for each Biometric screen: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens:
Sp= 00-shared/02 §3,Ty= typography §2,El= elevation §5,R= radius §4,Mo= motion §6. All components reference 00-shared/03. Screens are(planned)unless noted — no backend endpoint exists for them yet (see 12_API_Mapping.md).
1. Biometric Hub / Sync Status (/biometric)
Chrome: standard AppBar "Biometric", subtitle chip of tenant context; body scrollable; padding 16 (phone) / 24 (tablet+).
Layout (phone portrait):
[ AppBar: Biometric ] [Settings gear? no — module hub, back to settings]
Status summary card (AppCard)
"3 of 4 devices active" headlineSmall + icon check/alert
chip row: Active 3 · Inactive 0 · Offline 1 (DeviceStatus, biometric-device.schema.ts:7-11)
Last sync card (SyncStatusTile)
"Last sync 07:45 · 38 punches" + "Sync now" TextButton
Queue card (Dev persona)
"biometric-sync queue: 0 waiting · 2 failed" → deep link Bull-Board
Failed-sync banner (AppBanner error, when >0 failed jobs)
Empty state (AppEmptyState) "No devices — register one" + CTA
States:
- loading →
AppSkeleton(2 cards). - loaded → cards as above; failed-sync banner only when queue shows failures.
- queue-error → queue card shows "queue unavailable" (error chip), banner; no crash.
- offline →
AppOfflineBanner; hub shows cached last-good status (00-shared/06 §5). - permission → hub hidden entirely (route guard), not an error screen.
Data source (today): none of these endpoints exist — the screen consumes the
planned GET /biometric/devices + queue health (Bull-Board API). Until the
biometric-sync worker exists (queue.constants.ts:7, no @Processor), the queue card
will always read "0 processed" — ship the screen only after OQ-4 lands.
Motion: card entrance Mo.m-base stagger 40 ms; status chip color transition 200 ms.
A11y: summary is a live region on refresh; queue failure announced; targets ≥ 48.
Dark/light: token-only; offline badge errorContainer, active primaryContainer.
Tablet/desktop: three-column tile row ≥ 1200 px (summary, last sync, queue); hover
states; Tab order: summary → sync → queue → nav.
2. Device List (/biometric/devices)
Chrome: AppBar "Devices" + AppFAB "Register device"; list with RefreshIndicator.
Row (AppListTile):
leading: AppAvatar (device icon)
title: device name
subtitle: "{model} · {location}" (biometric-device.schema.ts:21-22, 27-28)
trailing: DeviceStatusBadge (active/inactive/offline) + AppMenu (Detail, Disable)
States:
- loading →
AppSkeleton(list). - empty →
AppEmptyStateiconsensors, "No devices registered", CTA inline. - error →
AppErrorStatecodesUNAUTHENTICATED(401 → session),INTERNAL(5xx + requestId). - row
offline→ trailing badgeoffline(error color) + subtle red-tinted tile. - disable →
AppDialogconfirm (destructive) → row removed on server 200 (no optimistic — irreversible operation, per00-shared/08).
Motion: row fade-in stagger 30 ms; badge swap Mo.m-fast.
A11y: each row one semantics node; badge text included in row label.
Tablet/desktop: master-detail — list left, detail pane right (screen 4); Enter on row opens detail.
3. Device Register (/biometric/devices/new)
Chrome: AppBar "Register device"; single AppCard form; CTA bottom AppButton
fullWidth "Register device" (h 48).
Fields (order): name*, deviceId* (monospace hint — printed on hardware), model?,
location?, status default active segmented, config? (collapsible raw JSON editor —
Dev persona only, revealed behind "Advanced" disclosure).
States:
- idle → CTA disabled until name + deviceId valid.
- submitting → CTA
loading; inputs disabled; no double-submit. - 409 duplicate → inline banner "A device with this ID already exists" (unique index
biometric-device.schema.ts:36). - success → navigate to device detail with snackbar "Device registered".
Motion: field focus Mo.m-fast; success → detail slide Mo.m-base.
A11y: labels linked to fields; first invalid focused on submit; advanced disclosure announced.
Keyboard: .next through fields, .done on deviceId submits.
4. Device Detail (/biometric/devices/:id)
Chrome: AppBar device name; status badge inline.
Content (top → bottom):
Header card: name · DeviceStatusBadge · model · location · deviceId (copyable)
Action row: [Sync now] [Enroll] [Edit] [Disable (destructive)]
Config card (collapsible): raw config JSON (biometric-device.schema.ts:30-31)
Sync card: SyncStatusTile — last sync, result, retry count
Punch preview: last 5 logs for this device (index tenantId+deviceId+timestamp, biometric-log.schema.ts:26)
Sync states (POST /biometric/devices/:id/sync):
- idle → "Sync now" enabled.
- running → button spinner + "Syncing…" (disabled), row pulse.
- success → "Sync complete · 38 punches" snackbar + preview refresh.
- failed → inline
AppBanner error"Sync failed — will retry (4×, exponential)" (blueprintRETRIES.md:38); exhausted → "moved to dead-letter" + link to Bull-Board.
Punch preview states: empty ("No punches synced yet") / list (PunchRow x5) / tap →
Log Detail sheet.
A11y: sync result announced via live region; copy button labeled.
5. Enrollment (/biometric/devices/:id/enroll)
Chrome: AppBar "Enroll student"; EnrollStepBar (1 Student → 2 Capture → 3 Confirm);
steps advance only on server/device confirmation.
Step 1 — Student: search field (name/roll) + result list (reuse student search); select → chip "Enrolled: {name}" if already enrolled (warning, confirm overwrite).
Step 2 — Capture: device-driven — "Place finger on scanner" + live status text (capture_ok / capture_fail). Failure hint: "dry hands / reposition" (persona Arjun), retry button; no client-side template processing ever (server/hardware owns the template; OQ-3).
Step 3 — Confirm: summary card (student, device, template fingerprint-hash preview — format undecided) + CTA "Confirm enrollment".
States:
- capture timeout → error step state + retry; device offline → blocked banner.
- duplicate → confirm-overwrite dialog (destructive style).
- success → device detail with "Enrolled: {name}" snackbar.
A11y: step bar announced; capture status live region; all targets ≥ 48.
Keyboard: step 1 search only; steps 2-3 are touch/pointer-first.
Note: this screen is fully blocked today — no template schema, no enrollment
endpoint, vendor SDK pending (FEATURE_ROADMAP.md:56, OQ-3). Design is frozen for review,
not for build.
6. Biometric Log List (/biometric/logs)
Chrome: AppBar "Biometric logs"; filter bar (student search chip, device dropdown,
date range picker, mode chips); RefreshIndicator; results as PunchRows.
Row: student avatar+name, deviceId · mode, timestamp (relative + absolute), status
dot unprocessed (grey) / processed (green — field planned, COLLECTIONS.md:1888).
States:
- loading skeleton; empty "No punches found" (or "No punches for this filter").
- error
AppErrorState; offline → cached last-good + banner. - tap row → Log Detail sheet (screen 7).
Filter persistence: filters survive navigation (client state only, no server query
params contract yet — planned GET /biometric/logs has no defined filter surface,
blueprint Biometric.md:30).
Tablet/desktop: two-pane list/detail; date-range quick presets (Today / This week).
A11y: each row announces timestamp; filter chip toggles announced.
7. Biometric Log Detail (bottom sheet)
Content:
student name + avatar headlineSmall
deviceId · mode bodyMedium
timestamp (absolute + tz) bodySmall
rawData JSON (collapsible) monospace, wrap — forensic payload (biometric-log.schema.ts:21-22)
"Immutable record — never updated" note (blueprint COLLECTIONS.md:1866)
[Open attendance record] → GET /attendance/student/:studentId (attendance.controller.ts:37-43)
States: loading (sheet opens with skeleton), loaded, not-found (row deleted — treat as removed, snackbar). Sheet is non-modal on tablet (pane).
8. Attendance-Verification Check-in (/biometric/verify)
Chrome: AppBar "Verify check-in"; search student → date selector (default today).
Content:
Student card: name, roll, class
Punch timeline (PunchTimeline): logs sorted desc — timestamp, device, mode
Derived status card:
matched → "Present · source: biometric" (AttendanceSource.BIOMETRIC, attendance.schema.ts:18)
mismatched → "Punch found, no attendance record — derivation gap" (warning banner)
no punch → "No punches for this day" (AppEmptyState compact)
[Edit attendance] (when matched/mismatched) → PATCH /attendance/:id (attendance.controller.ts:54-59)
States: loading; student-not-found → inline error; offline → verification blocked (evidence must be live — no cached verdicts).
A11y: verdict announced; timeline semantics grouping per punch.
9. Motion & state cross-cutting (all screens)
- Pull-to-refresh everywhere lists live; no cache for verdict screens.
- No optimistic mutation for disable/sync/enroll (server-confirm,
00-shared/08 §4). - Reduced-motion: only opacity cross-fades (
00-shared/08 §2).
10. Implementation status matrix
| Screen | Backend today | Client buildable |
|---|---|---|
| Hub/Sync Status | ✗ (OQ-4 blocks) | no |
| Device List/Register/Detail | ✗ (endpoints planned) | no |
| Enrollment | ✗ (OQ-3 blocks) | no |
| Log List/Detail | ✗ (GET /biometric/logs planned) | no |
| Verify Check-in | ✗ (derive + read planned) | no |
| QR/Push check-in | ✗ | (forward-looking), dormant |
07 — Component Library (Biometric Module)
- 1.
DeviceStatusBadge - 2.
SyncStatusTile - 3.
PunchRow - 4.
PunchTimeline - 5.
EnrollStepBar - 6.
StatusSummaryCard(hub) - 7.
QueueHealthChip(hub, Dev persona) - 8.
ConfigJsonViewer(device detail, Dev persona) - 9. Shared components reused (no module variants)
- 10. Status → color map (module tokens)
Module-specific widgets for the Biometric console, built on shared components (00-shared/03). Only new module components are specified here; everything else delegates to the shared library. All
(planned)until the screens can be built.
1. DeviceStatusBadge
| Concern | Spec |
|---|---|
| Purpose | Show DeviceStatus (biometric-device.schema.ts:7-11) |
| States | active → primaryContainer "Active"; inactive → surfaceVariant "Inactive"; offline → errorContainer "Offline" |
| Base | shared AppBadge + AppChips-style; fixed width 72 dp to prevent row jitter |
| Semantics | text "Device active/inactive/offline"; offline additionally announced "device offline" |
| Dark/light | token-only; no custom colors |
| Motion | 200 ms color transition on state swap |
2. SyncStatusTile
| Concern | Spec |
|---|---|
| Purpose | One device/job sync summary row (hub card + device detail) |
| Content | icon (check/error/hourglass), "Last sync {relative}" (from lastActivityAt-style field — planned), punch count, optional "Sync now" TextButton |
| States | idle / syncing (spinner, Mo.m-base pulse) / success (check) / failed (error icon + "will retry (4× exp)" — blueprint RETRIES.md:38) / queued (hourglass — job in biometric-sync, queue.constants.ts:7) |
| Slot | compact for list rows; expanded for hub card |
| Semantics | single node; state text in label |
3. PunchRow
| Concern | Spec |
|---|---|
| Purpose | One biometric_logs row in list/preview (biometric-log.schema.ts:9-19) |
| Content | student avatar+name, deviceId · mode, timestamp (relative + absolute), processed dot (planned field processed, COLLECTIONS.md:1888) |
| States | unprocessed (grey dot) / processed (green dot) / selected (hover/pressed) |
| Base | AppListTile; tap → detail sheet |
| Semantics | "punch, {student}, {time}, processed" |
4. PunchTimeline
| Concern | Spec |
|---|---|
| Purpose | Chronological evidence view (verify check-in screen) |
| Content | vertical timeline: dot + line, per punch PunchRow-lite (time, device, mode); day header grouping |
| Empty | "No punches" compact state |
| Semantics | Semantics grouping per day; each punch a node |
5. EnrollStepBar
| Concern | Spec |
|---|---|
| Purpose | 3-step wizard indicator (Enrollment screen) |
| Steps | 1 Student → 2 Capture → 3 Confirm |
| States | current (filled), done (check), future (disabled); step bar announced; only current/next tappable |
| Motion | progress fill 200 ms; step swap Mo.m-fast |
6. StatusSummaryCard (hub)
| Concern | Spec |
|---|---|
| Purpose | "3 of 4 devices active" fleet summary |
| Content | headline count + chip row (Active/Inactive/Offline counts — from planned GET /biometric/devices + DeviceStatus) |
| States | healthy (check icon), degraded (any offline/inactive → warning), empty ("no devices") |
| Base | AppCard; icon color by state |
7. QueueHealthChip (hub, Dev persona)
| Concern | Spec |
|---|---|
| Purpose | biometric-sync queue depth + failures |
| Content | "waiting · failed" counts; failed > 0 → error banner link to Bull-Board |
| Base | AppChips; live-region on refresh |
| Note | meaningless until worker exists (OQ-4) — render "queue unavailable" gracefully |
8. ConfigJsonViewer (device detail, Dev persona)
| Concern | Spec |
|---|---|
| Purpose | Read/edit config object (biometric-device.schema.ts:30-31) |
| Content | collapsible monospace JSON; edit mode with validate+save; never shows device secrets |
| Base | AppCard + disclosure; destructive save confirm |
| Note | blueprint stores device credentials encrypted in integrations (Biometric.md:59) — this viewer shows non-secret config only |
9. Shared components reused (no module variants)
AppCard, AppListTile, AppButton, AppFAB, AppMenu, AppDialog, AppSnackbar,
AppSkeleton, AppEmptyState, AppErrorState, AppOfflineBanner, AppBanner,
AppRefreshIndicator, AppBadge, AppChips, AppAvatar, AppTextField,
AppBottomSheet — all per 00-shared/03, no fork.
10. Status → color map (module tokens)
| Status | Token |
|---|---|
| active | primaryContainer / onPrimaryContainer |
| inactive | surfaceVariant / onSurfaceVariant |
| offline | errorContainer / onErrorContainer |
| processed (log) | tertiaryContainer |
| unprocessed (log) | surfaceVariant |
08 — Form Specifications (Biometric Module)
- 1. Ingest Punch (device webhook →
POST /biometric/ingest) - 2. Device Register —
POST /biometric/devices(planned) - 3. Device Edit —
PATCH /biometric/devices/:id(planned) - 4. Enrollment — device capture
(planned, blocked — OQ-3) - 5. Manual Punch Entry (clerk fallback) —
(proposed) - 6. Log Filters (Log List) —
(planned) - Form-level rules (all)
- Client-side error priority
Every form field-by-field. Validation mirrors class-validator decorators exactly from
src/modules/biometric/dto/create-biometric-log.dto.ts; planned forms follow the schema fields (biometric-device.schema.ts,biometric-log.schema.ts) and blueprint endpoints. Client validates inline then submits; server 400 shadows client.
1. Ingest Punch (device webhook → POST /biometric/ingest)
Real today (create-biometric-log.dto.ts, biometric.controller.ts:14-18). Not a
user form — the device/agent posts it; documented here because the client may preview a
manual "enter punch" path for clerks.
| # | Field | Type | Required | Validation (server) | Notes/UX |
|---|---|---|---|---|---|
| 1 | studentId | text (MongoId) | ✓ | @IsMongoId() (create-biometric-log.dto.ts:5-7) | shape-only check; no existence check (edge, 01 §8) |
| 2 | deviceId | text | ✓ | @IsString() (:9-11) | no registry check — any string accepted today |
| 3 | timestamp | datetime | ✓ | @IsDateString() (:13-15) | device clock time; keep original punch time for derivation correctness (blueprint COLLECTIONS.md:1883) |
| 4 | mode | text | – | @IsOptional() @IsString() (:17-20) | free string (finger/QR/card…) — no enum server-side |
Server response: created BiometricLogDocument (schema biometric-log.schema.ts:7-27).
rawData is schema-only — cannot be posted today (gap, 01 OQ-2).
2. Device Register — POST /biometric/devices (planned)
Endpoint blueprint Biometric.md:26; fields from biometric-device.schema.ts:15-31.
| # | Field | Type | Required | Validation (mirror) | Notes/UX |
|---|---|---|---|---|---|
| 1 | name | text | ✓ | @IsString() | autofocus; max 48 chars (client) |
| 2 | deviceId | text | ✓ | @IsString() | monospace hint "printed on device"; unique per tenant (:36) |
| 3 | model | text | – | @IsOptional() @IsString() | vendor model string |
| 4 | location | text | – | @IsOptional() @IsString() | "Main gate", "Library" |
| 5 | status | select | – (default active) | enum DeviceStatus (:7-11,24-25) | segmented Active/Inactive/Offline |
| 6 | config | JSON | – | @IsOptional() object | advanced disclosure; non-secret config only (07 §8) |
Errors: 409 duplicate deviceId → inline banner; 400 validation → per-field.
3. Device Edit — PATCH /biometric/devices/:id (planned)
Same fields as §2, all optional (partial update); status change to offline via "Disable"
confirm dialog rather than raw edit.
4. Enrollment — device capture (planned, blocked — OQ-3)
No HTTP form fields yet; the flow is device-driven (screen 06 §5). The eventual wire
payload per blueprint COLLECTIONS.md:1872-1892 + Biometric.md:57:
| Planned field | Type | Meaning |
|---|---|---|
employeeCode | string | vendor/user code — maps punch → studentId/userId via config |
| template | blob/hash (format TBD) | storage format undecided — hash? encrypted blob? vendor raw? (OQ-3) |
studentId / userId | MongoId | resolved identity (ref Student as in biometric-log.schema.ts:9-10) |
Enrollment form (client): student search → confirm. Never renders template bytes.
5. Manual Punch Entry (clerk fallback) — (proposed)
Post-sync-clinic fallback form = §1 fields + student search picker (resolves
studentId). Gated biometric.log.create (permissions.constants.ts:41); flagged
"manual entry" in mode so derivation/audit can distinguish (blueprint keeps raw payload
for replay, Biometric.md:56).
6. Log Filters (Log List) — (planned)
| Field | Type | Notes |
|---|---|---|
| student | search chip | deep-link ?student=:id |
| device | dropdown | from GET /biometric/devices |
| date range | date picker presets | Today / This week / Custom |
| mode | chips | multi-select; free strings client-side |
No server filter contract yet (planned GET /biometric/logs, blueprint Biometric.md:30).
Form-level rules (all)
- Double-submit: disabled while pending; device ingest must be idempotent-safe client side (retry sends identical body; dedupe is derivation's job — logs are immutable).
- Optimistic: no optimistic writes for register/disable/sync (server-confirm, 00-shared/08 §4).
- Keyboard:
.nextsequence,.donesubmits; register form 6 fields single column. - Error copy: from
messageof envelope for business 4xx (00-shared/07); codes for the rest.
Client-side error priority
- 400 VALIDATION → field.
- 401 UNAUTHENTICATED → session-expiry overlay (global).
- 403 FORBIDDEN → permission screen ("You don't have device access").
- 409 DUPLICATE_RESOURCE → inline (deviceId duplicate).
- 429 RATE_LIMITED → countdown (ingest is high-volume — device path should use a machine token, not user rate tiers — OQ-5).
- 5xx → AppErrorState.
09 — User Behaviour (Biometric Module)
- 1. Morning-gate burst (persona Riya, Arjun)
- 2. Trust the machine until it isn't (dispute behaviour)
- 3. Sync anxiety (IT admin Dev)
- 4. Permission-scoped curiosity (Meera, parents)
- 5. Evidence retention for disputes
- 6. Offline-device drift
- 7. Behaviour invariants (module)
Observed-behaviour patterns that drive the interaction design of the Biometric console. Derived from personas (02) and journeys (03); backend facts cited where behaviour is bounded by server reality.
1. Morning-gate burst (persona Riya, Arjun)
- Pattern: 07:30-09:00 the clerk is at the device, the ops manager is on the hub; both act in < 30 s windows between arrivals.
- Design consequence: hub must render health before interaction (no pull-to-refresh
as the only path); sync status must be glanceable (
SyncStatusTile, 07 §2); enroll flow ≤ 3 taps (persona Arjun). - Server bound: sync cadence is
*/15 * * * *(scheduler.service.ts:70-76) — the UI must never imply real-time; "next sync in ~12 min" copy is honest.
2. Trust the machine until it isn't (dispute behaviour)
- Pattern: teachers/parents accept biometric-derived attendance silently; the moment one mismatch appears, the whole pipeline is questioned.
- Design consequence: verify check-in screen (06 §8) is evidence-first: raw punch timeline above the derived verdict; "mismatch" state must say where it broke (punch exists, derivation missing), not a generic error.
- Server bound: logs are immutable (
biometric-log.schema.ts:7, blueprintCOLLECTIONS.md:1866) — the UI can promise "records can't be edited here" truthfully.
3. Sync anxiety (IT admin Dev)
- Pattern: Dev checks the queue before checking devices; failure means "did I lose data?".
- Design consequence: retry semantics surfaced verbatim — "4 retries, exponential
2000 ms, then dead-letter" (blueprint
RETRIES.md:38,Biometric.md:58); a "Sync now" escape hatch per device (POST /biometric/devices/:id/sync, blueprint:28). - Server bound: today the worker is missing (OQ-4) — until then, any sync UI reads "0 processed" and breeds distrust; ship sync UI only with the worker.
4. Permission-scoped curiosity (Meera, parents)
- Pattern: teachers peek at biometric screens only to settle "who was in"; parents never do.
- Design consequence: no biometric navigation for non-
biometric.*roles (permissions.constants.ts:41-43); teachers get derived attendance via their existingattendanceendpoints (attendance.controller.ts:31-35). Keep module boundary clean (AGENTS.md: module boundaries, no cross-module service calls).
5. Evidence retention for disputes
- Pattern: disputes surface days later; clerks need the original punch, not a summary.
- Design consequence: log detail shows absolute + relative timestamp,
deviceId,mode, raw payload (biometric-log.schema.ts:12-22); retention bound 1 year visible (blueprintDATA_RETENTION.md:25) so "why is April gone?" never surprises.
6. Offline-device drift
- Pattern: an offline device is ignored for days unless flagged; then a burst of manual re-entry.
- Design consequence: offline is a banner on the hub (not a badge); the device
detail suggests "Sync now" + notes punches arrive with original timestamps
(
COLLECTIONS.md:1883) so the catch-up is data-correct. - Server bound:
DeviceStatus.OFFLINEexists (biometric-device.schema.ts:10) but nothing sets it today (no health-check implementation, blueprintBiometric.md:19) — flag in QA (14 §7).
7. Behaviour invariants (module)
- Every verdict is backed by at least one visible raw punch — no "trust us" UI.
- Every destructive action (disable device, overwrite enrollment) has a confirm dialog.
- No screen pretends the pipeline is real-time; sync copy always references the 15-min cadence.
- Biometric data never appears on student/parent-facing surfaces (privacy, 14 §8).
10 — Interaction Specification (Biometric Module)
- 1. Pull-to-refresh (lists)
- 2. Sync trigger (device detail + hub)
- 3. Status badge transitions
- 4. Enrollment wizard
- 5. Row actions (device list / log list)
- 6. Verify check-in verdicts
- 7. Errors & empty states
- 8. Keyboard & focus (forms)
- 9. A11y notes
- 10. Interaction invariants
Micro-interactions and state transitions per surface. Motion tokens from 00-shared/08; every interaction below is server-confirm (no optimistic mutations) — devices and evidence are irreversible.
1. Pull-to-refresh (lists)
- Devices, Logs, hub status:
AppRefreshIndicatorre-fetches;Mo.m-fastsettle; refresh bypasses cache (00-shared/06 §3.1). - During refresh the previous content stays visible (no skeleton flash).
2. Sync trigger (device detail + hub)
| Trigger | Behavior |
|---|---|
| "Sync now" | button → spinner, disabled; row pulse; result snackbar "Sync complete · N punches" / "Sync failed" |
| Retry | same button after failure; copy "will retry (4×, exponential 2000 ms)" (blueprint RETRIES.md:38) |
| Dead-letter | failure banner + "View in Bull-Board" link; banner persists until resolved |
Motion: Mo.m-base for running; result swap Mo.m-fast fade.
3. Status badge transitions
- Badge color swap 200 ms; offline → tile tint
errorContainer(07 §10). - No animation loops; reduced-motion = opacity cross-fade only.
4. Enrollment wizard
- Step advance only on confirm; back allowed to step 1 (re-select student).
- Capture failure: inline error + "Retry capture"; device offline blocks step 2 with banner (no spinner hanging).
- Duplicate enrollment: destructive-style confirm ("Overwrite existing template?").
- Motion:
Mo.m-basehorizontal slide between steps; progress fill 200 ms.
5. Row actions (device list / log list)
| Action | Interaction |
|---|---|
| Open | tap → detail (master-detail pane on ≥ 840 px) |
| Menu | AppMenu: Detail / Disable (devices); Open / (logs) |
| Disable | AppDialog confirm → 200 → row removed + AppSnackbar; no undo (irreversible) |
| Filter chips | toggle → list refetch; active chip announced |
6. Verify check-in verdicts
- Verdict card transitions:
AnimatedSwitcherfade 200 ms; mismatch = warning banner- timeline stays visible (evidence-first, 09 §2).
- "Edit attendance" hop → attendance screen; back returns with verdict refreshed.
- Offline: verify blocked (evidence must be live) — banner + disabled CTA.
7. Errors & empty states
| Case | UI |
|---|---|
| Empty devices | AppEmptyState + inline CTA "Register device" |
| Empty logs (filter) | "No punches for this filter" + "Clear filters" button |
| 403 | permission screen, not error |
| 429 (ingest path) | countdown, no auto-retry (machine path should avoid — OQ-5) |
| Queue unavailable | "queue unavailable" chip on hub; never crash |
| Session expiry | global overlay (00-shared/07) |
8. Keyboard & focus (forms)
.nextsequence;.donesubmits register form; first invalid field focused on submit.- Advanced (config JSON) disclosure: keyboard-openable, focus inside editor.
9. A11y notes
- Live regions: sync result, verdict, badge changes, queue counts.
- All tap targets ≥ 48 dp; timeline nodes individually focusable.
- Monospace
rawDatareader:Semantics.label"raw device payload, read-only".
10. Interaction invariants
- No interaction implies real-time; sync copy references 15-min cadence.
- No destructive action without confirm; no undo promises.
- Every status change is server-confirmed before UI updates.
- Reduced-motion honored app-wide (00-shared/08 §2).
11 — Design System Mapping (Biometric Module)
- 1. Token usage (from 00-shared/02)
- 2. Component mapping (all shared, no forks — 07 for module widgets)
- 3. New module widgets (in 07, not global)
- 4. Semantic state mapping (module)
- 5. Dark/light parity
- 6. Typography rules
Maps the Biometric module onto the shared design system (00-shared/02 tokens, 00-shared/03 components, 00-shared/04 mapping). Module adds no new global tokens; it only introduces semantic mappings for biometric states.
1. Token usage (from 00-shared/02)
| Token group | Where |
|---|---|
Color: primary, primaryContainer | active states, CTA, "matched" verdict |
Color: error, errorContainer | offline, failed sync, mismatch, destructive confirms |
Color: tertiaryContainer | processed-punch indicator |
Color: surfaceVariant | inactive devices, unprocessed punch |
Type: headlineMedium / bodyMedium / bodySmall | screen titles, rows, metadata (06 layouts) |
Spacing: Sp-16 phone / Sp-24 tablet | screen padding |
Radius R-md | cards, fields, badges |
Elevation El-1 | cards; El-2 bottom sheet |
Motion Mo.m-fast / Mo.m-base | micro-interactions, screen transitions |
2. Component mapping (all shared, no forks — 07 for module widgets)
| Shared component | Module use |
|---|---|
AppCard | status summary, device header, config viewer |
AppListTile | device rows, punch rows |
AppButton / AppFAB | CTAs, "Register device" FAB |
AppMenu | row actions |
AppDialog | disable/overwrite-enrollment confirms |
AppSnackbar | sync result, register success |
AppBanner | failed sync, mismatch verdict |
AppSkeleton / AppEmptyState / AppErrorState | loading / empty / error |
AppOfflineBanner | connectivity |
AppRefreshIndicator | list refresh |
AppBadge / AppChips | status badge, filter chips, device status |
AppBottomSheet | log detail |
AppAvatar | student/device leading icons |
3. New module widgets (in 07, not global)
DeviceStatusBadge, SyncStatusTile, PunchRow, PunchTimeline, EnrollStepBar,
StatusSummaryCard, QueueHealthChip, ConfigJsonViewer — all compose shared
primitives; none introduce tokens.
4. Semantic state mapping (module)
| State | Color | Icon |
|---|---|---|
| active device | primaryContainer | sensors |
| inactive device | surfaceVariant | sensors_off |
| offline device | errorContainer | cloud_off |
| sync running | primary | spinner |
| sync failed | errorContainer | error_outline |
| punch processed | tertiaryContainer | check_circle |
| punch unprocessed | surfaceVariant | circle |
| verdict matched | primaryContainer | verified |
| verdict mismatch | errorContainer | warning_amber |
5. Dark/light parity
- All mappings token-based → automatic dark parity; QA pass required on badge contrast
(
errorContaineron dark) — 14 §9. - Monospace
rawDatausesbodySmall+monospacefamily on both themes.
6. Typography rules
- Timestamps always
bodySmallrelative + absolute (locale-aware, 00-shared/02 §2). - Never uppercase device names or punch data.
12 — API Mapping (Biometric Module)
- 0. Module-wide request envelope & client policy
- 1. Ingest punch — REAL today
- 2. Device management —
(planned)blueprint contract - 3. Attendance derivation —
(planned)event chain (not HTTP) - 4. Attendance endpoints the biometric screens reuse (real)
- 5. Permissions matrix
- 6. Loading / streaming / realtime
- 7. Client-side error mapping
- 8. Pagination
- 9. Optimistic / undo
Exact wire contract for every screen → endpoint. Base
/api/v1; envelope per 00-shared/07. Real endpoints fromsrc/modules/biometric/biometric.controller.ts; planned endpoints fromstudylyon-blueprint/04-Modules/Biometric.md:24-31. Global guards:RateLimitGuard→JwtAuthGuard→RbacGuard(app.module.ts:129-131). RbacGuard needs@Permissionsmetadata — the ingest endpoint has none (OQ-5).
0. Module-wide request envelope & client policy
| Aspect | Contract |
|---|---|
| Base | https://api.<domain>/api/v1 |
| Headers | Authorization: Bearer <accessToken>; x-request-id; JSON |
| response | {success,message,data,meta?,timestamp,requestId} |
| error | {success,message,error:{code,details?},timestamp,requestId} |
| Tenancy | tenantId from JWT claim (BaseRepository scoping); never in body |
| Offline | all writes blocked; reads cached last-good |
| Retry | backoff on 5xx/network; no auto-retry on 429 |
1. Ingest punch — REAL today
| Endpoint | POST /biometric/ingest |
| Source | biometric.controller.ts:14-18; @Post('ingest') + @UseGuards(JwtAuthGuard) (:9,14) |
| Request | {studentId: MongoId*, deviceId: string*, timestamp: ISO8601*, mode?: string} — create-biometric-log.dto.ts:4-20 |
| Success | 201 (created BiometricLogDocument) — biometric.service.ts:14-16 persists via logRepo.create |
| Auth | JWT (controller-local guard + global chain); no @Permissions metadata — any authenticated user (OQ-5) |
| Errors | 400 validation; 401 unauthenticated; 429 rate; 5xx |
| Client role | device webhook path; clerk manual entry (proposed) mirrors it (08 §5) |
| Note | rawData not accepted (schema-only, 01 OQ-2); response exposes schema fields incl. rawData echo |
Endpoint source: biometric.controller.ts:14-18, biometric.service.ts:14-16
2. Device management — (planned) blueprint contract
| Endpoint | Method | Source | Screen |
|---|---|---|---|
/biometric/devices | GET (list) | blueprint Biometric.md:25 | Device List |
/biometric/devices | POST (create) | blueprint :26 | Device Register |
/biometric/devices/:id | PATCH (update) | blueprint :27 | Device Detail / Edit |
/biometric/devices/:id/sync | POST (trigger sync) | blueprint :28 | Device Detail — Sync now |
/biometric/logs | GET (list, filters planned) | blueprint :30 | Log List |
Request/response fields mirror biometric-device.schema.ts:15-31 (name, deviceId, model,
status enum active|inactive|offline :7-11, location, config) and
biometric-log.schema.ts:9-22. Unique key (tenantId, deviceId) (biometric-device.schema.ts:36)
→ 409 on duplicate deviceId.
No implementation exists — UI build blocked (06 §10).
3. Attendance derivation — (planned) event chain (not HTTP)
| Hop | Mechanism | Source |
|---|---|---|
| Punch polled | biometric-sync queue, */15 * * * * | queue.constants.ts:7; scheduler.service.ts:70-76 |
| Worker | missing — no @Processor('biometric-sync') anywhere (grep src/**) | OQ-4 |
| Event emitted | BiometricImported → queue attendance-process | blueprint EVENTS.md:18 |
| Worker | attendance.worker.ts:16 @Processor('attendance-process') (real) | derives/processes attendance |
| Target model | AttendanceSource.BIOMETRIC = 'biometric' | attendance.schema.ts:18 |
| Existing trigger | AttendanceMarked event → attendance-process | event-queue-map.ts:14-17 |
4. Attendance endpoints the biometric screens reuse (real)
| Endpoint | Source | Used by |
|---|---|---|
GET /attendance/student/:studentId?startDate&endDate | attendance.controller.ts:37-43 | Verify Check-in, Log Detail hop |
PATCH /attendance/:id | attendance.controller.ts:54-59 | dispute correction |
GET /attendance/class/:classId?date | attendance.controller.ts:31-35 | teacher view (passive) |
5. Permissions matrix
| Permission | Constant | Endpoint(s) | permissions.constants.ts |
|---|---|---|---|
biometric.log.create | real | POST /biometric/ingest (metadata missing — OQ-5) | :41 |
biometric.log.read | real | planned GET /biometric/logs | :42 |
biometric.device.manage | real | planned device CRUD + sync | :43 |
biometric.sync / biometric.read | blueprint names — not in constants | — | blueprint Biometric.md:66-68 |
6. Loading / streaming / realtime
| Screen | Loading | Streaming | Realtime |
|---|---|---|---|
| hub | skeleton cards | — | (planned) WS sync-event push |
| devices | skeleton list | — | — |
| device detail | skeleton | — | — |
| logs | skeleton list | — | (planned) WS log push |
| verify | spinner | — | — |
7. Client-side error mapping
| Screen | code | UI |
|---|---|---|
| ingest | 400 | per-field (device path: log payload, no UI) |
| ingest | 429 | countdown; machine path → OQ-5 |
| devices | 409 | "A device with this ID already exists" |
| devices | 403 | permission screen |
| sync | 5xx/network | retry banner "will retry (4× exp) — DLQ after" (RETRIES.md:38) |
| any | 401 | silent refresh → fail: session-expiry overlay (00-shared/07) |
8. Pagination
Planned GET /biometric/logs: high-volume collection (blueprint INDEXING.md:68:
insert-only) → must paginate (cursor or page+size) with meta per envelope;
device list is small (non-paginated array, like auth sessions pattern).
9. Optimistic / undo
None. Register/disable/sync/enroll are server-confirm; disable is irreversible → confirm dialog, no optimistic removal (10 §5).
13 — State Management (Biometric Module)
- 1. Server-side sync job lifecycle (source of truth for UI)
- 2. Per-screen Cubits
- 3. State objects (concise)
- 4. Events & actions map (UI → Cubit → API)
- 5. Repository
- 6. Caching & refresh
- 7. Error states per action
- 8. Testing hooks (
00-shared/06 §6) - 9. Cross-cutting interplay
Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) + the server-side sync job state machine the UI must mirror. Backed by
BiometricRepository(dio) which calls the endpoints in 12_API_Mapping.md. All screens are(planned)— cubits are designed against the blueprint contract, not shipped today.
1. Server-side sync job lifecycle (source of truth for UI)
stateDiagram-v2
[*] --> scheduled : repeatable job (*/15 * * * *)
scheduled --> enqueued : scheduler fires (queue: biometric-sync)
enqueued --> active : worker picks up
active --> completed : punches ingested
active --> failed : device offline / vendor error
failed --> active : retry (4x, exp 2000ms)
failed --> dlq : retries exhausted
dlq --> [*] : manual replay (Bull-Board)
completed --> [*] : done
Sources: queue.constants.ts:7 (queue), scheduler.service.ts:70-76 (cadence),
blueprint RETRIES.md:38 (4 retries / exp 2000 ms), Biometric.md:58 (→ DLQ).
The active transition has no worker today (OQ-4) — SyncCubit must treat
"queued forever" as an error state ("queue unavailable").
2. Per-screen Cubits
| Screen | Cubit | Events → State |
|---|---|---|
| Hub/Sync Status | BiometricHubCubit | Load, Refresh, SyncNow → {initial, loading, loaded(devices, queue), degraded, error} |
| Device List | DeviceListCubit | Load, Refresh, Disable(id) → {initial, loading, loaded([device]), empty, error, disabling} |
| Device Register | DeviceRegisterCubit | Register(form) → {idle, submitting, success(device), duplicate, error} |
| Device Detail | DeviceDetailCubit | Load(id), Sync(id), Update(id, patch) → {initial, loading, loaded(device, lastPunches), syncing, syncResult, error} |
| Enrollment | EnrollCubit | SelectStudent, Capture, Confirm → {selecting, capturing, captureFailed, confirming, enrolled, duplicate} |
| Log List | LogListCubit | Load, Refresh, ApplyFilters(f), ClearFilters → {initial, loading, loaded([punch]), empty, error} |
| Verify Check-in | VerifyCheckinCubit | Load(student, date), OpenAttendance → {idle, loading, matched, mismatched, noPunch, error} |
Conventions: LoadState from 00-shared/06 §3.1; lists refresh via RefreshIndicator
bypassing cache; verdict screens never serve cached data.
3. State objects (concise)
class DeviceStatus { active, inactive, offline } // biometric-device.schema.ts:7-11
class Device { id, name, deviceId, model?, status, location?, config?, createdAt, updatedAt }
class Punch { studentId, deviceId, timestamp, mode?, rawData?, processed? } // processed planned
class SyncState { enum {idle, running, succeeded, failed, dlq}; int punches; int retries; }
class EnrollFlow { student?, step; captureState; enrolledStudent? }
class VerifyVerdict { enum {matched, mismatched, noPunch}; List<Punch> punches; }
4. Events & actions map (UI → Cubit → API)
| UI event | Cubit method | Repository call |
|---|---|---|
| Hub refresh | load() | biometricRepo.devices() + queueHealth() (Bull-Board) |
| Hub Sync now | syncNow(id) | biometricRepo.syncDevice(id) |
| Devices refresh | load() | biometricRepo.devices() |
| Row disable | disable(id) | biometricRepo.updateDevice(id, {status:'inactive'}) |
| Register submit | register(form) | biometricRepo.createDevice(form) |
| Detail sync | sync(id) | biometricRepo.syncDevice(id) |
| Detail punches | loadPunches(id) | biometricRepo.logs(device: id, page: 1) |
| Enroll confirm | confirm() | biometricRepo.enroll(deviceId, studentId) (planned) |
| Logs filter | applyFilters(f) | biometricRepo.logs(filters) |
| Verify | verify(student, date) | biometricRepo.logs(student: s, date: d) + attendanceRepo.student(s, d, d) |
5. Repository
class BiometricRepository {
Future<List<Device>> devices();
Future<Device> createDevice(RegisterDeviceDto dto);
Future<Device> updateDevice(String id, Map<String, dynamic> patch);
Future<SyncResult> syncDevice(String id);
Future<Paginated<Punch>> logs({String? studentId, String? deviceId, DateTimeRange? range, String? mode, int page});
Future<EnrollResult> enroll(String deviceId, String studentId); // planned
}
6. Caching & refresh
- Devices: cache last-good list (
local_cache),RefreshIndicatorre-fetch (00-shared/06 §5). - Logs: no persistence cache — evidence must be live; filters are session state only.
- Verify verdicts: never cached.
- Queue health: refresh on hub open + pull-to-refresh; treat missing worker as
error.
7. Error states per action
| Action | Error | State → |
|---|---|---|
| sync | 5xx/network | syncResult.failed(retries) → banner "will retry (4× exp)" |
| sync | DLQ | syncResult.dlq → Bull-Board link |
| disable | 409/403 | snackbar; row unchanged |
| register | 409 | duplicate → inline deviceId banner |
| verify | 404 student | noPunch verdict is data, not error; 404 student → inline |
| any | 401 | global refresh → session-expiry overlay |
8. Testing hooks (00-shared/06 §6)
- Pure-Dart cubits; unit-test the sync state machine (diagram §1) and verify verdict transitions (matched/mismatched/noPunch).
- Widget tests: hub degraded state; device list empty/error; enroll capture-fail retry; log filter chips.
- Integration: register → sync → log appears (mock server); retries exhausted → dlq state.
9. Cross-cutting interplay
ConnectivityCubitgates sync/enroll (offline → banner, no silent failure).AuthCubitsession expiry applies globally;FeatureFlagsCubit(proposed)could gate the whole biometric subtree while enrollment is vendor-gated (FEATURE_ROADMAP.md:56).- Attendance cubits are not extended here — biometric surfaces read attendance via repository calls only (module boundary, AGENTS.md).
14 — QA Checklist (Biometric Module)
- 1. Functional — ingest pipeline (REAL today)
- 2. Functional — device sync (
(planned); queue stub today) - 3. Functional — device CRUD
(planned) - 4. Functional — verify check-in
(planned) - 5. Template enrollment & storage
(planned — OQ-3) - 6. False-match / verification quality
(planned) - 7. Device sync failures & health
- 8. Visual / layout
- 9. Accessibility (00-shared/09)
- 10. Performance
- 11. Offline & network (10 §2)
- 12. Permissions & security
- 13. Localization
- QA scripts
Module QA specific to framework + shared 00-shared/10. Every check derives from a specific backend behavior or blueprint rule so failure analysis is direct. The first block is testable today; the rest activates as planned endpoints land.
1. Functional — ingest pipeline (REAL today)
-
POST /api/v1/biometric/ingestwith valid body → 201, returned doc hasstudentId/deviceId/timestamp/mode+tenantIdfrom JWT (biometric.controller.ts:14-18,biometric.service.ts:14-16). -
400 on bad
studentId(non-MongoId) / missingdeviceId/ non-ISOtimestamp(create-biometric-log.dto.ts:4-20). -
modeoptional; omitted OK; extra body fieldrawDatarejected silently or dropped — verify which (schema has it, DTO doesn't — OQ-2). - Duplicate ingest (same body twice) → two log rows (logs immutable; dedupe is derivation's job — confirm this is intended, OQ-6).
-
Tenant isolation: tenant A's log never visible via tenant B repo queries
(
BaseRepositoryscoping). -
Unauthenticated request → 401 (global
JwtAuthGuard,app.module.ts:130). -
No permission check — any authenticated user can ingest (no
@Permissions; confirm product intent, OQ-5).
2. Functional — device sync ((planned); queue stub today)
-
biometric-syncrepeatable job registered*/15 * * * *(scheduler.service.ts:70-76). - Known gap: no worker — jobs enqueue and stall. QA script: observe queue depth growth; expect 0 processed (OQ-4).
-
When worker lands: retries = 4, exponential 2000 ms (blueprint
RETRIES.md:38); exhausted jobs reach DLQ (blueprintBiometric.md:58). -
POST /biometric/devices/:id/synctriggers immediate run(planned).
3. Functional — device CRUD (planned)
-
Create: unique
(tenantId, deviceId)enforced (biometric-device.schema.ts:36); duplicate → 409. -
statusrestricted toactive|inactive|offline(:7-11); defaultactive(:24-25). -
PATCH partial update;
configobject round-trips.
4. Functional — verify check-in (planned)
-
Punch timeline matches
biometric_logsindex order(tenantId, studentId, timestamp -1)(biometric-log.schema.ts:26). -
AttendanceSource.BIOMETRICrows render "source: biometric" (attendance.schema.ts:18). -
Mismatch verdict appears when punch exists but no derived attendance (derivation
gap — currently guaranteed, since derivation is
(planned)). - No-punch day → clear evidence-absent state (never "absent" verdict without proof).
5. Template enrollment & storage (planned — OQ-3)
- Storage format decision recorded (hash / encrypted blob / vendor raw) before UI build — nothing stored today; verify no template bytes in any response.
- Template size bound defined (e.g., ≤ N KB per template) and enforced server-side.
-
"Maps to a verified student record" acceptance (
ACCEPTANCE_CRITERIA.md:39). - Enrollment overwrite requires confirm; duplicate warning surfaced.
-
Privacy/security: templates never in logs, analytics, or screenshots; encrypted
at rest; device config credentials live in
integrations, encrypted (blueprintBiometric.md:59).
6. False-match / verification quality (planned)
- Vendor SDK acceptance threshold documented; FAR/FRR targets defined before pilot.
- Dry-hand / misread failure path reaches UI (capture-fail, retry) — no silent drop.
- Mismatch between punch and attendance flagged, never auto-corrected.
7. Device sync failures & health
-
Offline device detection — nothing sets
OFFLINEtoday (blueprint health-checkBiometric.md:19unbuilt): verify who flips status once implemented. - Sync failure banner copy matches retry policy (4× exp → DLQ).
-
Catch-up punches preserve original
timestamp(blueprintCOLLECTIONS.md:1883). -
Retention:
biometric_logs> 1 year TTL+archive (blueprintDATA_RETENTION.md:25); dispute UI explains vanished records.
8. Visual / layout
- Hub reads at a glance (3-device summary ≤ 2 s); offline is banner-grade, not badge-grade.
- Device/punch rows stable width (badge 72 dp fixed) — no jitter on refresh.
- Dark-light parity on all state colors (11 §5).
9. Accessibility (00-shared/09)
- TalkBack/VoiceOver: hub summary + sync results + verdicts announced (live regions).
- Keyboard-only desktop: register form TAB order; menus operable.
- Timeline nodes individually focusable; raw payload reader labeled read-only.
- Contrast: state chips 3:1, body 4.5:1; reduced-motion → fades only.
10. Performance
-
Ingest p95 < 1 s (PRD
PRODUCT_REQUIREMENTS_DOCUMENT.md:136). -
Log list paginated (high-volume insert-only collection, blueprint
INDEXING.md:68); no full-scan client fetches. - Memory stable over long log scrolling.
11. Offline & network (10 §2)
- All writes blocked offline (banner + disabled CTA); reads cached last-good.
- Verify verdicts never cached — offline = blocked, not stale.
- Sync retry no-op if already running (single-flight).
12. Permissions & security
-
Route guards: Devices subtree needs
biometric.device.manage; Logs subtree needsbiometric.log.read(permissions.constants.ts:41-43). -
biometric.syncpermission missing from constants (blueprintBiometric.md:67) — decide name before sync UI ships. - Tokens never logged; ingest bodies never in analytics.
- No template/biometric data on student-parent surfaces (privacy posture).
- Machine ingest path uses dedicated token, not user rate tiers (OQ-5).
13. Localization
- All strings i18n; status words (Active/Inactive/Offline) translatable, not hardcoded.
- Timestamps locale-aware (relative + absolute).
QA scripts
- Golden per component (light/dark × phone/tablet/desktop × key states).
- E2E (when endpoints land): register device → sync → ingest → verify check-in → dispute → attendance hop.
- Security smoke: cross-tenant ingest attempt (expected: tenant-scoped rejection); unauthenticated ingest (401); template response leak check.
- Pipeline drill: kill device mid-sync → expect 4 retries → DLQ → replay.
15 — Flutter Implementation Guide (Biometric Module)
- 1. Folder structure
- 2. Dependencies
- 3. Cubits
- 4. BiometricRepository (single)
- 5. Navigation
- 6. Theme
- 7. Extensions
- 8. Localization keys
- 9. Storage
- 10. Testing
- 11. Performance
- 12. Proposals flagged to the team
How to build the Biometric feature in the Flutter client on top of 00-shared/11. Forward-looking spec; no client repo exists yet. Scope note: the PRD excludes native mobile apps — web-first (
PRODUCT_REQUIREMENTS_DOCUMENT.md:144) — so this guide builds the admin console (device/logs/verification), not the capture surface. Capture stays on vendor hardware + SDK (FEATURE_ROADMAP.md:56); QR/push check-in is(forward-looking).
1. Folder structure
features/biometric/
├── domain/
│ ├── models/
│ │ ├── device.dart # DeviceStatus, name, deviceId, model, location, config
│ │ ├── punch.dart # studentId, deviceId, timestamp, mode, rawData?, processed?
│ │ ├── sync_state.dart # idle/running/succeeded/failed/dlq + punches + retries
│ │ └── verify_verdict.dart # matched/mismatched/noPunch + punches
│ └── exceptions/biometric_exceptions.dart
├── data/
│ ├── dto/
│ │ ├── register_device_dto.dart
│ │ └── update_device_dto.dart
│ └── repositories/
│ └── biometric_repository.dart
└── presentation/
├── cubit/
│ ├── biometric_hub_cubit.dart
│ ├── device_list_cubit.dart
│ ├── device_register_cubit.dart
│ ├── device_detail_cubit.dart
│ ├── enroll_cubit.dart
│ ├── log_list_cubit.dart
│ └── verify_checkin_cubit.dart
├── pages/
│ ├── biometric_hub_page.dart
│ ├── device_list_page.dart
│ ├── device_register_page.dart
│ ├── device_detail_page.dart
│ ├── enroll_page.dart
│ ├── log_list_page.dart
│ └── verify_checkin_page.dart
└── widgets/
├── device_status_badge.dart
├── sync_status_tile.dart
├── punch_row.dart
├── punch_timeline.dart
├── enroll_step_bar.dart
├── status_summary_card.dart
├── queue_health_chip.dart
└── config_json_viewer.dart
2. Dependencies
flutter_bloc, dio (AppDio with refresh/error interceptors), go_router, get_it,
intl, json_view (raw payload viewer). No biometric SDK in the app — capture is
device-side; the console only reads results. No local template storage, ever.
3. Cubits
All per 13_State_Management.md; pure Dart, unit-testable. BiometricHubCubit is the
only module-wide state (fleet summary); others are screen-scoped. No singleton beyond the
repository.
4. BiometricRepository (single)
class BiometricRepository {
Future<List<Device>> devices(); // planned GET /biometric/devices
Future<Device> createDevice(RegisterDeviceDto dto); // planned POST /biometric/devices
Future<Device> updateDevice(String id, UpdateDeviceDto); // planned PATCH /biometric/devices/:id
Future<SyncResult> syncDevice(String id); // planned POST /biometric/devices/:id/sync
Future<Paginated<Punch>> logs({filters…}); // planned GET /biometric/logs
Future<EnrollResult> enroll(String deviceId, String studentId); // planned
}
Ingest is not called from the app UI (device webhook); a clerk manual-entry form
(proposed) would call it via the same dio stack with biometric.log.create gating.
5. Navigation
- go_router routes per
04:/biometric,/biometric/devices,/biometric/devices/new,/biometric/devices/:id,/biometric/devices/:id/enroll,/biometric/logs,/biometric/verify. - Route guard: any
biometric.*perm for hub;biometric.device.managefor devices subtree;biometric.log.readfor logs/verify (permissions.constants.ts:41-43). - Deep link
/biometric/logs?student=:idfor dispute sharing.
6. Theme
No new tokens (11_Design_System_Mapping.md); state colors mapped to existing M3 roles.
config_json_viewer monospace on both themes.
7. Extensions
DeviceStatus.presentable()→ localized label + color role.DateTime.toRelative()reused from auth package for punch timestamps.Punch.processedColor()→ tertiary/surfaceVariant (07 §10).
8. Localization keys
biometric.hub.*, biometric.device.*, biometric.enroll.*, biometric.log.*,
biometric.verify.* — full list in 08_Form_Specifications.md; server messages mapped
to keys, fallback to message for business 4xx only.
9. Storage
- No biometric data persisted locally. Only non-sensitive caches: last-good device list,
last-good hub status (
00-shared/06 §5). Never cache punches or verdicts.
10. Testing
- Unit: sync state machine (
13 §1); verdict transitions; DTO→model mappers. - Widget: hub degraded/healthy; device list empty/error; enroll capture-fail retry; log filter chips.
- Golden: all module widgets light/dark × 3 sizes (
00-shared/10 §9). - Integration (mock server): register device → sync → punch appears → verify verdict.
- E2E (when backend lands): register → sync → ingest (simulated device) → dispute → PATCH attendance.
- Security smoke: unauthenticated ingest 401; cross-tenant isolation; no template in any payload.
11. Performance
- Log list paginated +
ListView.builder; punch timeline virtualized per day group. - Hub renders from cached summary first, updates on refresh.
- No full-screen rebuild on sync state change —
SyncStatusTilescoped rebuilds.
12. Proposals flagged to the team
- Build order blocked by backend: device CRUD + logs endpoints, then the
biometric-syncworker (OQ-4), then sync-status UI. The hub screen must not ship with a permanently-empty queue card. - Template storage decision (OQ-3) gates enrollment UI; until then keep
EnrollCubitdesigned but unshipped. biometric.syncpermission name vsbiometric.device.manage— settle before RBAC metadata lands on sync endpoints.- Analytics wiring waits
00-sharedAnalyticsService(proposed). - QR/push check-in stays dormant (
forward-looking) — route exists, no UI.
01 — Product Overview (Bulk Module)
- 1. What the module is
- 2. Scope in / scope out
- 3. Sync vs async — the two engines
- 4. Import pipeline (exact)
- 5. Duplicate handling
- 6. 1000-row behaviour (large files)
- 7. CSV column contract (import)
- 8. Platform & client scope notes
- 9. Goals (product)
- 10. Non-goals (per source)
Generic CSV bulk import/export for tenant entities. Derived from
src/modules/bulk/**(controller, service, adapter, interface, module),src/modules/users/**,src/modules/students/**,src/modules/rbac/permissions.constants.ts,src/infrastructure/bullmq/{queue.constants.ts,event-queue-map.ts},docs/IMPLEMENTATION_PLAN.md, and the shared ledgerdesign-docs/00-shared/**. Nothing in this doc is invented; plan-only capability is marked(planned), client-only or roadmap-only capability(forward-looking), analytics(proposed).
1. What the module is
The Bulk module is the generic CSV pipeline for tenant data: one endpoint
pattern imports any entity with a registered adapter, one exports it. Today it
ships exactly one adapter — students (students-import.adapter.ts:15);
the service itself notes the registry is deferred until a second entity joins
(bulk-import.service.ts:15-16).
Key facts from source:
- Two routes, both Bearer-JWT protected (
JwtAuthGuard,bulk.controller.ts:28-31):POST /api/v1/bulk/import/:entity— multipart upload, fieldfile(bulk.controller.ts:35-48).GET /api/v1/bulk/export/:entity— CSV download (bulk.controller.ts:50-60).
- Import is synchronous and in-process:
csv-parse/sync→ per-row validate → per-row create → in-memory report returned in the same request (bulk-import.service.ts:22-65). There is no BullMQ queue, no worker, no result-polling endpoint (queue.constants.ts:1-17lists 15 queues; none is a bulk queue). - Report contract
ImportReport {entity, totalRows, imported, failed, errors: [{rowNumber, errors[]}]}(import-adapter.interface.ts:14-25); every error carries the physical CSV row number (header = row 1,bulk-import.service.ts:46). - The students adapter creates one user per row via
UsersService.create(students-import.adapter.ts:66-71) — which emitsUserCreated(users.service.ts:64-76) →in-appqueueuser-created-notification(event-queue-map.ts:10) — and one student viaStudentService.create(students-import.adapter.ts:76-84) — which emitsStudentCreated(student.service.ts:79-91) →in-appqueuestudent-enrolled(event-queue-map.ts:28). Import rows therefore DO fan out notifications, unlike the users module's inline import which bypasses events (see01_Product_Overview.md §4in the users package). - Permissions exist in
permissions.constants.ts:10(user.import) and:7(user.create),:25-28(student.read/create/update/delete) — but no RBAC guard is attached to the bulk routes; onlyJwtAuthGuard. Permission enforcement is(planned). - Module wiring:
bulk.module.ts:11-17importsUsersModule,StudentsModule,AcademicsModule; registered in the root app (app.module.ts:126).
2. Scope in / scope out
| In scope (implemented) | Out of scope (owned elsewhere / not yet) |
|---|---|
students import (user + student + auto-enrollment per row) | Other entities: fees, library, exams — (planned) (bulk-import.service.ts:15-16; IMPLEMENTATION_PLAN.md:172) |
students CSV export | Async import workers with BullMQ progress + rollback — (planned) (IMPLEMENTATION_PLAN.md:172) |
| Per-row validation + per-row error report | CSV template download endpoint ((planned) client-side only) |
| Duplicate detection (email, admission number) | Bulk edit / bulk update semantics — none in code |
| CSV round-trip (import ↔ export) | Attendance bulk marking — separate endpoint POST /attendance/bulk (attendance.service.ts:48), not this module |
Users inline import POST /users/import — separate engine in Users module (users.controller.ts:104-110) |
3. Sync vs async — the two engines
There are two distinct CSV import implementations in source; both are synchronous:
| Path | Endpoint | Parser | Report shape | Errors shape |
|---|---|---|---|---|
| A. Bulk module (this doc) | POST /api/v1/bulk/import/:entity (bulk.controller.ts:35-48) | csv-parse/sync — quoted fields, trim, skip empty (bulk-import.service.ts:26-30) | {entity,totalRows,imported,failed,errors} | {rowNumber, errors[]} per row |
| B. Users inline | POST /api/v1/users/import (users.controller.ts:104-110) | naive split(',') (users.service.ts:236-252) | {imported, errors} | flat string[] "Row N: msg" |
Async import is planned but not built: IMPLEMENTATION_PLAN.md:172
("Generic service, CSV/Excel, validation, BullMQ progress, rollback; integrate
Students, Fees, Library, Exams") and a students-specific
POST /api/v1/students/bulk-import (IMPLEMENTATION_PLAN.md:195). The shared
ledger's glossary already says "Bulk import: CSV upload processed
asynchronously by workers" (00-shared/01 §10) — that is ahead of the
code; flag it as a documented discrepancy (see 14_QA_Checklist.md §9).
4. Import pipeline (exact)
- Request → multipart
filerequired, else 400VALIDATION_ERROR"CSV file is required (multipart field "file")." (bulk.controller.ts:43-46). - Buffer decoded as UTF-8 (
bulk.controller.ts:47). - Parse with
csv-parse/sync{columns:true, skip_empty_lines:true, trim:true}(bulk-import.service.ts:26-30); unparsable CSV → 400VALIDATION_ERROR"Malformed CSV: could not parse file." (:31-33); header-only/empty → 400 "CSV must include a header row and data." (:34-35). - Unknown entity → 404
RESOURCE_NOT_FOUNDNo import adapter for entity "X".(bulk-import.service.ts:17-20). - Per row (rowNumber = index + 2, header is row 1 —
:45-46): adaptervalidate(row); any errors →failed+1,errors.push({rowNumber, errors}), row skipped (:47-52); else adaptercreate(row)→imported+1, or on throw →failed+1with the thrown message as the row error (:53-62). One bad row never aborts the batch (bulk-import.service.spec.ts:53-65). - Report returned in the 200 envelope.
5. Duplicate handling
The students adapter resolves duplicates at validate time (row rejected, never re-used):
- admissionNumber exists →
Admission number "X" already exists.(students-import.adapter.ts:49-54, viaStudentRepository.findByAdmissionNumber). - email registered →
Email "X" already registered.(students-import.adapter.ts:55-56, viaUsersService.findByEmail).
Race fallback at create time: if UsersService.create throws (email
ConflictException, users.service.ts:50-54), the adapter re-fetches the
existing user and reuses its _id (students-import.adapter.ts:72-75) — so a
duplicate that slips past validation still results in a student row, not a
crash.
6. 1000-row behaviour (large files)
- Every row is processed sequentially with
await(bulk-import.service.ts:45), and each row performs multiple DB round-trips: duplicate checks (2 reads) + ref resolution (3-4 reads: academicYear, grade, section, class —students-import.adapter.ts:100-144) + user create (1 write) + student create + enrollment (2 writes). A 1,000-row file is therefore roughly 1,000 × 7-9 DB operations inside a single HTTP request, with the full report held in memory (bulk-import.service.ts:37-43). - Consequences for the client: no progress events, no partial-report resume, risk of gateway/proxy timeouts on very large files, and the API rate-limit budget (api tier 100/min, 00-shared/07 §4) is consumed by one import.
- Design rule: the client must treat the import as a long-running
synchronous call, show an indeterminate progress state, and cap file size
client-side (see 06, 10, 15). The production answer — queue + progress
- polling — is
(planned)(IMPLEMENTATION_PLAN.md:172).
- polling — is
7. CSV column contract (import)
From the adapter (students-import.adapter.ts:17-26): header names are
exact camelCase (no normalization like the users inline import):
| Column | Required | Notes |
|---|---|---|
firstName | yes | → user firstName |
lastName | yes | → user lastName |
email | yes | format-checked; must not already be registered |
admissionNumber | yes | must not already exist |
grade | yes | matched by name OR code (:118-121) |
section | yes | matched by name (:122-125) |
academicYear | yes | matched by name (:113-117) |
rollNumber | no | optional; omitted when blank (:79) |
References must resolve to existing records — the adapter never creates grades/sections/years (error strings in 08_Form_Specifications.md §2).
8. Platform & client scope notes
- Native-app exclusion (PRD): the PRD puts native mobile apps out of Phase
1 scope ("Native mobile apps (web-first)",
PRODUCT_REQUIREMENTS_DOCUMENT.md:144); the shared ledger resolved to spec a full-featured Flutter client now, against the complete API surface (00-shared/01 §9). All screens, routes, and Flutter guidance in this package are(forward-looking)by extension. Bulk import is a desktop/web-admin workflow (file picker, large tables) with phone support limited to result review. - API is
v1, Bearer JWT, tenant from token only (00-shared/07 §1,§6); wire contract and error codes per 00-shared/07 §2-§3 (success{success:true,message:"OK",data,meta?,timestamp,requestId}; codes 400/401/403/404/409/422/429/5xx). - Push/QR tooling
(forward-looking); analytics(proposed)— no SDK chosen (00-shared/12 A4).
9. Goals (product)
- Bulk onboarding — an admin imports hundreds of students from a school's existing spreadsheet in one upload, with a per-row error report so rows can be fixed and re-uploaded without re-entering good rows.
- Safe by default — nothing is imported until the file parses; duplicates are rejected per row; a bad row never blocks the batch; re-upload is idempotent (already-imported rows fail as duplicates, not as corruption).
- Round-trip — export CSV as a template/reference for future imports
(
GET /bulk/export/students,students-import.adapter.ts:87-98). - Extensible — the adapter contract
(
import-adapter.interface.ts:6-12) lets fees/library/exams join with one new file each(planned).
10. Non-goals (per source)
- No async import job, no queue, no progress events, no rollback — all
(planned)(IMPLEMENTATION_PLAN.md:172). - No generic template endpoint, no per-entity pagination of errors, no download-error-CSV endpoint (client can synthesize).
- No bulk update/delete — import only creates.
- No permission enforcement on the routes yet (
user.importexists but is unbound,permissions.constants.ts:10) —(planned)RBAC guard. - No analytics instrumentation in this module
(proposed).
02 — User Personas (Bulk Module)
- 1. Persona map
- 2. Admissions Admin — "Mira"
- 3. Registrar — "Daniel"
- 4. Org Admin — "Priya"
- 5. IT/Platform Support — "Alex"
- 6. Excluded personas
- 7. Permission reality check (for design)
Who drives bulk import/export, and what each persona needs from it. Overlaid on the platform roles in 00-shared/01 §3. Derived from the bulk module source; no invented users. Analytics
(proposed).
1. Persona map
| Persona | Role / permission | Bulk behaviour | Screens (05) |
|---|---|---|---|
| Admissions Admin (admin team) | user.import, user.create, student.create, student.read (permissions.constants.ts:7,10,25-26) | Runs the majority of imports at admission time; fixes and re-uploads rejected rows | SS1-SS4 |
| Registrar / Front-office staff | student.read + limited create (permissions.constants.ts:25-26) | Exports current rosters; occasionally imports small batches | SS1, SS4 |
| Org Admin | all org perms (rbac.member.*, organization.*) | Owns entity coverage decisions, audits import outcomes, unlocks future entities (planned) | SS1-SS4 |
| IT/Platform Support | platform-level | Diagnoses malformed files, encoding issues, and timeouts | SS4 (debug copy) |
| Class Teacher / Parent / Student | student.read-adjacent or none | No bulk access — read-only consumers of imported data elsewhere | — |
2. Admissions Admin — "Mira"
- Context: end of admission season; 400+ new enrollments arrive as an Excel sheet from the admissions office; she must have them in the system by Friday.
- Goal: one upload → everyone created → a report that names exactly which rows failed and why.
- Needs: template to fill (
GET /bulk/export/students); upload; clear per-row errors; fix-and-reupload without retyping good rows; confidence that re-uploading a file that already succeeded won't duplicate students. - Pain today: the API is synchronous — a large file blocks the request;
there is no queue/progress (
bulk-import.service.ts:22-65), so she needs progress UI and a size cap. Duplicates are rejected per row (students-import.adapter.ts:49-56) — good news for idempotent re-upload. - Success metric: 100% of rows either imported or explained in the error list; no duplicated users; ≤ 2 upload attempts per batch.
3. Registrar — "Daniel"
- Context: weekly export of the current student roster for the state department.
- Goal: a single click produces
students.csvwithadmissionNumber, rollNumber, status, admissionDate(students-import.adapter.ts:92-97), sorted by admission number (:90-91). - Needs: export preserves sort; file downloads as an attachment named
<entity>.csv(bulk.controller.ts:55-58); content-typetext/csv(:52). - Pain today: none — export is stateless and fast. He occasionally wants the same file as the import template — note the export shape is not the import shape (export omits names/grade/section/email; see 04 §6).
4. Org Admin — "Priya"
- Context: decides which entities are maintained by bulk operations; owns tenant configuration.
- Goal: students today; teachers, fees, library catalog, exam marks
(planned)— adapter registry deferred (bulk-import.service.ts:15-16). - Needs: a visible inventory of supported entities on the Import screen so
the UI never offers what the API 404s on (
bulk-import.service.ts:17-20). - Pain today: entity list is hard-coded in the backend; the client must
hard-code
studentstoo until(planned)discovery lands.
5. IT/Platform Support — "Alex"
- Context: tickets like "import failed" or "file hangs".
- Needs: the exact error strings and row numbers from the report; the
request-relative root causes: malformed CSV (400,
bulk-import.service.ts:31-33), empty CSV (:34-35), unknown entity (404,:17-20), missing file (400,bulk.controller.ts:43-46). - Diagnosis notes: any UTF-8/BOM/encoding quirk, CRLF line endings, quoted
commas, and whitespace around headers (trim is per-cell
:29, header names are not normalized —students-import.adapter.ts:17-26).
6. Excluded personas
- Students/Parents: never touch bulk surfaces; imported users receive
in-app notifications via
UserCreated/StudentCreatedevents (event-queue-map.ts:10,28) — they are recipients, not actors. - Teachers: no bulk entity today
(planned)teacher adapter. - Mobile-first users: bulk is a desktop/web-admin workflow
(
(forward-looking)client; 00-shared/01 §9).
7. Permission reality check (for design)
- Route guard today is JwtAuthGuard only (
bulk.controller.ts:28-31); RBAC enforcement onuser.import/student.createis(planned). The UI should show the bulk entry point only to users the client can identify as admin-capable (client-side gate), and handle 403 gracefully once the server guard lands. - No
student.importpermission exists (permissions.constants.ts:25-28hasstudent.read/create/update/deleteonly) — the intended import permission isuser.import(:10)(planned)binding.
03 — User Journey (Bulk Module)
- 1. Journey map
- J1 — First-time bulk import (happy path)
- J2 — Import with row errors (partial success)
- J3 — Fix and re-upload (idempotency)
- J4 — Export roster / template
- J5 — Malformed / wrong file (failure)
- Cross-journey notes
End-to-end journeys through the bulk import/export flow. Screen references (
SSn) resolve in 05_Screen_Inventory.md; exact API behaviour in 12_API_Mapping.md. Forward-looking client on top of the live API.
1. Journey map
| # | Journey | Persona | Screens | Outcome |
|---|---|---|---|---|
| J1 | First-time bulk import | Admissions Admin | SS1 → SS2 → SS3 → SS4 | All rows imported; report shown |
| J2 | Import with row errors | Admissions Admin | SS1 → SS2 → SS3 → SS4 | Partial success; error list reviewed |
| J3 | Fix and re-upload | Admissions Admin | SS4 → SS2 (corrected) → SS4 | Previously failed rows now import; successes not duplicated |
| J4 | Export roster / template | Registrar | SS5 | students.csv downloaded |
| J5 | Malformed / wrong file | any admin | SS2 → SS1 (error) | Clear failure, file not partially imported |
J1 — First-time bulk import (happy path)
- Entry — Bulk/Import menu (client gate: admin-capable user, 02 §7).
- SS1 Upload — picks
students.csv(file picker,.csvonly); client parses in an isolate to render a preview (15 §4); "Next" enabled when headers match the contract (08 §1). - SS2 Preview — header map (contract vs file), row count, sample rows; mismatches and missing required columns flagged before upload; "Import N rows" CTA.
- SS3 Confirm — reads the risk summary: "N rows will create N users and N student records. Duplicates and reference mismatches will be skipped."
- SS4 Result —
POST /bulk/import/students; indeterminate progress (sync request); on 200, report renders: imported/failed/total, per-row error table. All-success → celebratory summary + links (view students, download report). - Exit — back to Students list; imported users/students are live immediately (synchronous create; no queue).
J2 — Import with row errors (partial success)
- Same entry through SS3.
- SS4 — report shows
imported < totalRows,failed > 0; error table with row numbers and messages, e.g.Email "x" already registered./Academic year "2026-27" not found.(08 §2). - Error rows are grouped by cause (duplicates / missing refs / bad format) for fast triage; each row links to its line in the file.
- User downloads an error CSV (client-synthesized: original row + error column) for offline fixing — no server endpoint exists for this.
J3 — Fix and re-upload (idempotency)
- From SS4, "Fix errors" → re-upload the corrected file (SS2).
- Already-imported rows now fail as duplicates (admission number / email,
students-import.adapter.ts:49-56) — expected and explained: report showsimported ≈ previously-failed rows, duplicates listed as duplicates. - No duplicate users are created even if a race occurs — the create-time
fallback reuses the existing user id (
students-import.adapter.ts:72-75). - Design copy must teach this: "duplicate" ≠ "problem" on re-upload (14_QA_Checklist.md §5).
J4 — Export roster / template
- SS5 Export — entity selector (
studentsonly today), "Download CSV". GET /bulk/export/students→text/csvattachmentstudents.csv(bulk.controller.ts:50-60).- Content:
admissionNumber, rollNumber, status, admissionDate(students-import.adapter.ts:92-97), sorted by admissionNumber (:90-91). - Templating caveat (flagged): the export is a roster snapshot, not an
import template — it lacks
firstName/lastName/email/grade/section/ academicYear. UI copy must not claim "download a template from Export"; a true template is client-generated(forward-looking)(04 §6).
J5 — Malformed / wrong file (failure)
- Wrong entity offered (e.g. "teachers") → client blocks it (entity list is
hard-coded
students); if it slips through, server 404No import adapter for entity "teachers".(bulk-import.service.ts:17-20) → SS4 error state. - Malformed CSV (unclosed quote, bad encoding) → client preview fails first
(isolate parse, 15 §4); if the file bypasses preview, server 400
Malformed CSV: could not parse file.(bulk-import.service.ts:31-33). - Empty CSV (header only) → 400
CSV must include a header row and data.(:34-35). - Missing file field → 400
CSV file is required (multipart field "file").(bulk.controller.ts:43-46). - Nothing is partially imported on any of these — parse happens before the first row is touched.
Cross-journey notes
- No async queue exists — every journey above blocks on one HTTP request
per upload; async workers with BullMQ progress/rollback are
(planned)(IMPLEMENTATION_PLAN.md:172) and will later turn SS4's indeterminate bar into a progressed job (design keeps that seam open, 13 §4). - Large files (≥ 1000 rows): the journey is identical but slow; UI must
pre-cap size (recommended 1000 rows / ~2 MB) and warn before upload
(10 §6); server-side row limit
(planned). - Events: every imported row emits
UserCreated(in-app notification,event-queue-map.ts:10) andStudentCreated(in-appstudent-enrolled,event-queue-map.ts:28) — students/parents may react to onboarding notifications; the importing admin does not need to send anything manually.
04 — Information Architecture (Bulk Module)
- 1. Placement in the global IA
- 2. Sitemap (screens)
- 3. Entity taxonomy (backend-constrained)
- 4. Import flow state machine (summary; full in
13) - 5. The report as an information object
- 6. Export shape ≠ import shape (flagged)
- 7. Error string taxonomy (SS4 grouping)
- 8. Navigation rules
Navigation, hierarchy, and the data contract that screens expose. Global IA in 00-shared/05; components in 00-shared/03. Sources: bulk module source files, students adapter, users/students services.
1. Placement in the global IA
Org Admin / Dashboard
└── People (or Admin section per module)
├── Users / Students list (module surfaces)
└── Import & Export ────► Bulk hub (SS1) ← this module
├── SS2 Preview
├── SS3 Confirm
├── SS4 Result / Error review
└── SS5 Export
- Entry: one top-level "Import / Export" destination, not buried per-entity;
entity choice is the first decision inside the flow (only
studentstoday,students-import.adapter.ts:15). - Exit: SS4 → Students list, SS5 → file download.
2. Sitemap (screens)
| # | Screen | Route (forward-looking) | Purpose |
|---|---|---|---|
| SS1 | Bulk Home / Upload | /admin/bulk | entity picker + file picker + template hint |
| SS2 | CSV Preview | /admin/bulk/preview | header map, sample rows, contract warnings |
| SS3 | Confirm | /admin/bulk/confirm | risk summary + go/no-go |
| SS4 | Result & Error Review | /admin/bulk/result | report rendering + error table |
| SS5 | Export | /admin/bulk/export | entity selector + download |
3. Entity taxonomy (backend-constrained)
entitypath param is a free string; onlystudentsresolves (bulk-import.service.ts:17-20). Everything else → 404.(planned)entities perIMPLEMENTATION_PLAN.md:172: fees, library, exams (and teachers, by symmetry). Until discovery exists, the client ships a hard-coded entity list['students'].
4. Import flow state machine (summary; full in 13)
upload → preview → confirm → importing (in-flight) → done { success | partial | failed }
↘ review errors → fix → re-upload
5. The report as an information object
ImportReport (import-adapter.interface.ts:14-25) is the single source of
truth for SS4:
| Field | Type | Meaning |
|---|---|---|
entity | string | echoed entity slug |
totalRows | number | data rows parsed (header excluded; bulk-import.service.ts:45-46) |
imported | number | rows whose create succeeded |
failed | number | rows rejected by validate OR create (:48-62) |
errors | {rowNumber, errors[]}[] | one entry per failed row; rowNumber is the physical CSV line (header = 1) |
Invariant: imported + failed === totalRows (every row is either counted or
errored — :45-63). The UI can assert this to detect client/server drift.
6. Export shape ≠ import shape (flagged)
Import columns (students-import.adapter.ts:17-26) | Export columns (:92-97) |
|---|---|
| firstName, lastName, email, admissionNumber, grade, section, academicYear, rollNumber | admissionNumber, rollNumber, status, admissionDate |
- Export = roster snapshot (
status,admissionDateare system-owned;admissionDateis ISO string,:96). - Import = onboarding payload. Never present Export as "the template";
template is a client-side concern
(forward-looking).
7. Error string taxonomy (SS4 grouping)
| Group | Strings (exact, 08 §2) | Where thrown |
|---|---|---|
| Missing / invalid field | Missing required column "X"., Invalid email format. | students-import.adapter.ts:43,47 |
| Duplicate | Admission number "X" already exists., Email "X" already registered. | :54,56 |
| Reference not found | Academic year "X" not found., Grade "X" not found., Section "X" not found., No class found for grade "X" section "Y". | :117,121,125,140 |
| Create-time | arbitrary thrown message (e.g. duplicate-key race) | bulk-import.service.ts:57-62 |
8. Navigation rules
- SS2 → SS3 disabled when contract warnings are blocking (
08 §1). - SS4 error rows link back to the source file's line number; "re-upload corrected file" returns to SS2 with the failed rows pre-highlighted by row number.
- Breadcrumb:
Bulk → Import → Preview → Confirm → Result.
05 — Screen Inventory (Bulk Module)
- Legend
- 1. Bulk Home / Upload (
/admin/bulk) - 2. CSV Preview (
/admin/bulk/preview) - 3. Confirm (
/admin/bulk/confirm) - 4. Result & Error Review (
/admin/bulk/result) - 5. Export (
/admin/bulk/export) - Shared components used
- Analytics events
(proposed) - Keyboard, landscape, tablet, desktop
Every screen of the Bulk module, its intent, route, composition, states, permissions, platform behaviour and events. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics. Full detail in 06_Screen_Specifications.md.
Legend
States = idle / loading / success / empty / error(offline, rate, invalid) /
disabled / permission. Analytics events follow {module}.{screen}.{action}
(proposed); SDK open — 00-shared/10 §8. Routes are (forward-looking).
1. Bulk Home / Upload (/admin/bulk)
| Field | Detail |
|---|---|
| Purpose | Choose entity + pick a CSV file; the entry point of the import wizard |
| Entry | admin nav "Import / Export"; post-error return from SS4 |
| Exit | valid file + headers → /admin/bulk/preview; help → expandable |
| Inputs | entity selector (hard-coded students — students-import.adapter.ts:15; others (planned)), file picker (.csv) |
| Primary CTA | Next (disabled until file parses + headers recognized) |
| Secondary | "Download template" (client-generated (forward-looking)); "Export instead" → SS5 |
| States | idle; picking; parsing (isolate — 15 §4); error (unreadable file, wrong extension, encoding/BOM failure); permission (client gate, 02 §7) |
| Content | column contract summary (08 §1); max size hint 1000 rows / ~2 MB (10 §6) |
| Analytics | bulk.upload.pick, bulk.upload.parse_fail(reason) (proposed) |
| Keyboard | file picker is the only interaction; entity selector keyboardable |
| Adaptive | desktop/web primary; phone: single-column with compact file card |
| a11y | file drop-zone as a real button with label; parse errors live-region |
| Motion | m-fast file card confirmation; errors shake |
2. CSV Preview (/admin/bulk/preview)
| Field | Detail |
|---|---|
| Purpose | Show header map, row count, sample rows, contract warnings — before anything hits the server |
| Entry | SS1 valid file |
| Exit | Next → /admin/bulk/confirm; Back → SS1; blocked warnings → stays |
| Data | client-parsed CSV (columns:true, trim mirror of bulk-import.service.ts:26-30) |
| Widgets | CSVPreviewTable (module-specific), AppChips (required/optional/unknown header tags), AppAlert |
| Primary CTA | Import N rows (disabled when blocking warnings exist) |
| States | preview; warnings (unknown header, missing required — 08 §1); empty (no data rows → block, mirrors 400 bulk-import.service.ts:34-35) |
| Warnings | non-blocking: unknown columns ignored; blocking: required column missing |
| Analytics | bulk.preview.view, bulk.preview.warnings(n) (proposed) |
| Adaptive | wide table on desktop; on phone horizontal scroll + sticky header column |
| a11y | table semantics, header associations, warning announcements |
| Motion | rows fade-in m-fast; warnings expand m-base |
3. Confirm (/admin/bulk/confirm)
| Field | Detail |
|---|---|
| Purpose | Final go/no-go with the risk summary |
| Entry | SS2 |
| Exit | Start import → SS4 (in-flight); Back → SS2 |
| Content | "N rows → N users + N student records"; duplicates/reference mismatches will be skipped, not imported (students-import.adapter.ts:49-56); re-upload is safe (idempotent) |
| Primary CTA | Start import (filled) |
| States | idle; (in-flight happens on SS4) |
| Analytics | bulk.confirm.start(n) (proposed) |
| a11y | summary as a list, not an image |
4. Result & Error Review (/admin/bulk/result)
| Field | Detail |
|---|---|
| Purpose | Render the ImportReport and drive fix/re-upload |
| Entry | after confirm in-flight; deep-link from report URL (forward-looking) |
| Exit | Fix errors → SS1 (pre-highlight failed row numbers); View students → students list; Download report (client-synthesized CSV) |
| Data | 200 envelope data = ImportReport {entity,totalRows,imported,failed,errors[{rowNumber,errors[]}]} (import-adapter.interface.ts:14-25) |
| Widgets | ImportSummaryCard, ErrorReviewTable (module-specific), AppSnackbar, AppEmptyState |
| Primary CTA | success: View students; partial: Fix errors; full fail: Fix errors |
| States | in-flight (sync request — indeterminate bar; no progress events exist, bulk-import.service.ts:22-65); success (all rows); partial; failed-before-rows (400 malformed/empty :31-35, 404 entity :17-20); offline/429 (00-shared/07 §4); permission |
| Error grouping | duplicates / reference-not-found / format (04 §7) |
| Analytics | bulk.result.view(imported,failed), bulk.result.download_errors (proposed) |
| Adaptive | error table dense on phone; paginated > 50 errors |
| a11y | report summary live-region; table rows announced with row number |
| Motion | summary count-up m-base; errors expand/collapse |
5. Export (/admin/bulk/export)
| Field | Detail |
|---|---|
| Purpose | Download entity CSV |
| Entry | SS1 secondary CTA; admin nav |
| Exit | file download; back to SS1 |
| Data | GET /api/v1/bulk/export/:entity → text/csv attachment <entity>.csv (bulk.controller.ts:50-60) |
| Widgets | entity selector, AppButton, AppSnackbar |
| Primary CTA | Download CSV |
| States | idle; downloading; empty (no rows → still a header-only CSV — stringify(rows,{header:true}) with [], bulk-import.service.ts:67-71); 404 entity |
| Note | export ≠ import template (04 §6) — copy must say "roster snapshot" |
| Analytics | bulk.export.download(entity) (proposed) |
| Keyboard | single selector + button |
Shared components used
AppButton, AppTextField (entity override search (forward-looking)),
AppSnackbar, AppCard, AppAlert, AppChips, AppEmptyState,
AppSkeleton, AppOfflineBanner, AppDialog, AppMenu, AppFAB (SS5
"Import instead"). Module-specific: FileDropZone, CSVPreviewTable,
ImportSummaryCard, ErrorReviewTable, ErrorRowTile, ImportStepper —
defined in 07_Component_Library.md.
Analytics events (proposed)
bulk.upload.{pick,parse_fail}, bulk.preview.{view,warnings},
bulk.confirm.{start} (with row count), bulk.result.{view,download_errors, reupload} (imported/failed counts), bulk.export.{download}.
Keyboard, landscape, tablet, desktop
- Preview/result tables: desktop full width; tablet two-pane (summary left, errors right); phone horizontal scroll, sticky first column.
- File picker: desktop native dialog; phone
file_selector/document picker. - All screens scroll;
resizeToAvoidBottomInsetwhere inputs exist (SS1 search field(forward-looking)).
06 — Screen Specifications (Bulk Module)
- Legend
- SS1 — Bulk Home / Upload
- SS2 — CSV Preview
- SS3 — Confirm
- SS4 — Result & Error Review
- SS5 — Export
- Cross-screen requirements
Detailed specification of every bulk screen: layout, content, states, interactions, error handling, exact copy and backend citations. The biggest document in this package. Components in 07, forms/CSV contract in 08, motion in 00-shared/08, QA in 14. Forward-looking client.
Legend
SSn= screen id (see 05). Routes are(forward-looking).- States: idle / loading / success / empty / error / disabled / permission.
- "Server 400/404" notes cite exact backend behaviour
(
bulk-import.service.ts,bulk.controller.ts).
SS1 — Bulk Home / Upload
1.1 Layout (desktop ≥ 1280 dp)
┌──────────────────────────────────────────────────────────────┐
│ Breadcrumb: Admin › Import & Export [Export →]│
├──────────────────────────────────────────────────────────────┤
│ Card: Import data (width ≤ 720, centered) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Entity [ Students ▾ ] (only option; others │ │
│ │ │ │
│ │ File ╔══════════════════════════════════╗ │ │
│ │ ║ Drop students.csv here or ║ │ │
│ │ ║ [ Browse files ] ║ │ │
│ │ ║ .csv only · ≤ 1000 rows · ≤2 MB║ │ │
│ │ ╚══════════════════════════════════╝ │ │
│ │ [ Expected columns ] ▾ (08 §1 contract summary) │ │
│ │ │ │
│ │ [ Back ] [ Next → ] │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
1.2 Behaviour
- Entity selector: hard-coded
['students']— the only adapter isstudents(students-import.adapter.ts:15); others(planned)(IMPLEMENTATION_PLAN.md:172). Selector present anyway to future-proof; disabled-looking when single option. - File pick:
.csvextension filter; MIME not trusted (server reads bytes,bulk.controller.ts:47). On selection the client immediately parses the file in an isolate (15 §4) mirroring server optionscolumns:true, skip_empty_lines:true, trim:true(bulk-import.service.ts:26-30). - Parse outcomes:
- Success → file card shows name, row count (data rows = lines −
header), detected headers;
Nextenabled. - Unparsable (unclosed quote, binary, garbage) → inline error
"This file could not be read as CSV." (client-side mirror of server 400
Malformed CSV: could not parse file.—bulk-import.service.ts:31-33). - Zero data rows → "CSV must include a header row and data."
(mirror of
:34-35);Nextstays disabled. - Encoding/BOM: strip UTF-8 BOM before parse (
14 §2); non-UTF-8 (e.g. Windows-1252 accents) → warning card with "encode as UTF-8" guidance — the server decodes UTF-8 only (bulk.controller.ts:47).
- Success → file card shows name, row count (data rows = lines −
header), detected headers;
Next→ SS2.Backdisabled on SS1 (it is the flow root).
1.3 States
| State | Visual | Notes |
|---|---|---|
| idle | card, Next disabled | |
| parsing | file card skeleton, spinner | isolate parse is ms-level for ≤ 1000 rows |
| error | AppAlert error under file zone | per 14 §2 encodings |
| permission | empty state "You don't have import permission" | client gate only; server RBAC (planned) (permissions.constants.ts:10) |
| offline | AppOfflineBanner; pick still allowed, Next blocked | no offline import (00-shared/06 §3.5) |
1.4 Analytics (proposed)
bulk.upload.pick(entity, rows), bulk.upload.parse_fail(reason).
SS2 — CSV Preview
2.1 Layout
┌──────────────────────────────────────────────────────────────┐
│ Import › Preview file: students.csv │
│ [ImportStepper: Upload ✓ · Preview ● · Confirm · Result] │
├──────────────────────────────────────────────────────────────┤
│ Header map (chips per column) │
│ firstName [required] · lastName [required] · email [required]│
│ admissionNumber [required] · grade [required] │
│ section [required] · academicYear [required] │
│ rollNumber [optional] · phone [unknown!] │
│ ⚠ 1 unknown column ("phone") will be ignored │
│ ⚠ blocking: missing required column "middleName"? no — │
│ list ACTUAL missing: e.g. missing "grade" │
├──────────────────────────────────────────────────────────────┤
│ # firstName lastName email admissionNumber … │
│ 2 Amara Osei amara.o@school.x A-2026-001 … │
│ 3 Bilal Khan bilal.k@school.x A-2026-002 … │
│ … (first 10 rows; "showing 10 of 413") │
│ [Back] [Import 413 →] │
└──────────────────────────────────────────────────────────────┘
2.2 Header contract logic (client-side, exact to server)
Compare file headers (as read, no case normalization — the server matches
exact names: students-import.adapter.ts:17-26) against:
| State | Classification | Blocking? |
|---|---|---|
| header ∈ required set | [required] chip | — |
header ∈ optional set (rollNumber) | [optional] chip | — |
| header not in contract | [unknown] chip + warning "will be ignored" | no |
| required column absent | AppAlert error listing column name(s) | yes (Next disabled) |
Trim nuance: the server trims cell values (trim:true,
bulk-import.service.ts:29) and the adapter trims values again
(students-import.adapter.ts:41-44) — but headers are not normalized.
A header " firstName" will produce rows keyed " firstName" and every
required check will fail with Missing required column "firstName".
(:43). The client must surface whitespace in header chips ("leading space
detected") as blocking — this is the #1 real-world import failure
(14 §2).
2.3 Preview table
- Shows first 10 data rows; row numbers shown are physical line numbers
(header = 1) so they match server
rowNumberin errors (bulk-import.service.ts:46). - Empty after parse (0 data rows) → blocking state with server-exact copy
CSV must include a header row and data.(:34-35). - Duplicate emails/admissionNumbers within the file: client can warn
(
(forward-looking); server only checks against DB,students-import.adapter.ts:49-56) — flagged as enhancement, not required.
2.4 CTA
Import N rows → SS3. Disabled when any blocking warning exists.
SS3 — Confirm
3.1 Content (exact, honest copy)
Import 413 rows as students
• 413 rows will create 413 user accounts (firstName, lastName, email)
and 413 student records with auto-enrollment into their grade/section
(student.service.ts:71-78).
• Rows whose email or admissionNumber already exist will be skipped and
listed as errors — they are not overwritten (students-import.adapter.ts:49-56).
• Grade/section/academicYear must already exist; unmatched values are
skipped with "not found" errors (students-import.adapter.ts:113-125).
• Re-uploading a file that already succeeded is safe: completed rows fail
as duplicates, new rows import.
• This import runs in one request — keep this tab open until the result
appears (bulk-import.service.ts:22-65). ← large-file caveat
[Back] [Start import]
3.2 Rules
- No new data entry on this screen; pure review.
Start importtriggers the synchronousPOST /bulk/import/students(bulk.controller.ts:35-48) and navigates to SS4 in-flight state immediately — the request must not be awaited before navigating (progress UI lives on SS4;10 §6).- Double-tap protection: CTA disabled once fired (idempotent server, but
double-fire wastes the 100/min api budget —
00-shared/07 §4).
SS4 — Result & Error Review
4.1 States
a) In-flight
┌────────────────────────────────────────────────┐
│ Importing 413 rows… │
│ [Indeterminate progress bar] │
│ • All rows are validated and created server- │
│ side in this single request. │
│ • Do not close this tab; you will see the │
│ report when it finishes. │
└────────────────────────────────────────────────┘
- No progress events exist (synchronous service,
bulk-import.service.ts:22-65); the UI is honest: indeterminate bar + explanation. When async workers land ((planned),IMPLEMENTATION_PLAN.md:172), this becomes a determinate progress + polling seam (13 §4). - Offline/timeout: on network failure the request may have partially
completed server-side (rows already created). Copy: "The connection was
lost. Check your students list, then re-upload — completed rows will be
reported as duplicates, not recreated." (idempotency by design,
students-import.adapter.ts:49-56).
b) Success (imported === totalRows)
┌────────────────────────────────────────────────┐
│ ✓ 413 of 413 rows imported │
│ • 413 users created (in-app notifications │
│ sent via UserCreated → in-app queue │
│ user-created-notification, event-queue-map.ts:10) │
│ • 413 students enrolled (StudentCreated → │
│ in-app student-enrolled, event-queue-map.ts:28) │
│ [View students] [Download report] │
└────────────────────────────────────────────────┘
c) Partial (failed > 0)
┌────────────────────────────────────────────────┐
│ ⚠ 398 of 413 rows imported · 15 failed │
│ [Error groups] Duplicates 9 · References 5 · │
│ Format 1 │
│ ┌─ ErrorReviewTable ──────────────────────────┐ │
│ │ Row │ Fields │ Error │ │
│ │ 17 │ email, admissionNumber │ Email "…" │ │
│ │ │ │ already │ │
│ │ │ │ registered│ │
│ │ 88 │ academicYear │ Academic │ │
│ │ │ │ year "…" │ │
│ │ │ │ not found │ │
│ │ … (paged 50)│ │
│ └──────────────────────────────────────────────┘ │
│ [Download error CSV] [Fix errors →] [View students] │
└────────────────────────────────────────────────┘
- Error rows show: physical row number (matches file line — server
rowNumber = index+2,bulk-import.service.ts:46), affected column(s), full error string(s) — exact server strings in08 §2. - Error CSV is client-synthesized: original row +
errorcolumn (no server endpoint exists). - "Fix errors" → SS1 with failed row numbers pre-queued so the preview
can highlight them after the corrected file is picked (
03 J3). - Duplicate rows are labelled with a "likely already imported" hint on
re-upload journeys (
03 J3), normalizing the duplicate-noise problem.
d) Failed before any row (server rejects the file)
| Server trigger | Code/message | SS4 rendering |
|---|---|---|
| no file field | 400 CSV file is required (multipart field "file"). (bulk.controller.ts:43-46) | client prevents this (always sends file); show generic + retry |
| unknown entity | 404 RESOURCE_NOT_FOUND No import adapter for entity "X". (bulk-import.service.ts:17-20) | "Entity not available" + back to SS1 |
| malformed CSV | 400 VALIDATION_ERROR Malformed CSV: could not parse file. (:31-33) | "File could not be parsed" + back to SS1 (preview normally catches this) |
| header-only CSV | 400 VALIDATION_ERROR CSV must include a header row and data. (:34-35) | same pattern |
e) Rate-limited / permission
- 429 (
RATE_LIMITED, api tier 100/min —00-shared/07 §4): countdown copyRetry in Ns, no auto-retry. - 401: session refresh flow (
00-shared/06 §3.6). - 403: only when RBAC guard lands
(planned); render permission empty state.
4.2 Invariants the UI can assert
imported + failed === totalRows (04 §5). If the client detects a mismatch
it renders a data-error banner with the raw report for support.
4.3 Realtime
No WS topic exists for import results; notifications to imported users are
server-side only. (forward-looking): a bulk.import.completed WS topic once
async workers exist.
SS5 — Export
5.1 Layout
Import & Export › Export
Entity [ Students ▾ ] (hard-coded list; students only)
Format CSV (text/csv) (fixed)
Note: roster snapshot — columns admissionNumber, rollNumber, status,
admissionDate; sorted by admissionNumber (students-import.adapter.ts:87-98).
Not an import template (04 §6).
[Download CSV]
5.2 Behaviour
GET /api/v1/bulk/export/students(bulk.controller.ts:50-60); responsetext/csv+Content-Disposition: attachment; filename="students.csv"(:52,55-58).- Empty roster → header-only CSV (
stringify([], {header:true}),bulk-import.service.ts:67-71); UI shows "No rows exported" toast, still downloads the header file. - Unknown entity → 404 (
:17-20); client prevents via hard-coded list. - The file downloads through the browser; on phone a share sheet
(forward-looking).
5.3 Analytics (proposed)
bulk.export.download(entity).
Cross-screen requirements
| Concern | Rule |
|---|---|
| Stepper | SS1→SS2→SS3→SS4; SS5 outside wizard (ImportStepper, 07) |
| Back | preserves wizard state (file + preview) so users can fix without re-picking when possible |
| Unsaved state | no forms with server writes until SS3; leaving SS3 before firing has no effect |
| Large file | SS1 cap + warning; SS3 caveat; SS4 in-flight honesty (10 §6) |
| a11y | live-region on parse errors and result summary; tables with real semantics (00-shared/09) |
| Motion | m-fast micro-feedback, m-base screen transitions (00-shared/08) |
07 — Component Library (Bulk Module)
- 1. Module-specific components
- 2.
FileDropZone - 3.
ImportStepper - 4.
CSVPreviewTable - 5.
ColumnChip - 6.
ContractAlert - 7.
ImportSummaryCard - 8.
ErrorReviewTable/ErrorRowTile - 9.
ImportProgressPanel(in-flight) - 10.
ExportCard - 11. Global components consumed (from 00-shared/03)
Module-specific components for the bulk screens. Global components come from 00-shared/03_Component_Library.md; this file defines only what is bulk- specific and names the global components each screen composes.
1. Module-specific components
| Component | Used by | Purpose |
|---|---|---|
FileDropZone | SS1 | drag-drop + browse CSV picker |
ImportStepper | SS1-SS4 | upload → preview → confirm → result |
CSVPreviewTable | SS2 | header map + sample rows |
ColumnChip | SS2 | required / optional / unknown header tag |
ContractAlert | SS2 | blocking vs non-blocking header warnings |
ImportSummaryCard | SS4 | counts + status hero |
ErrorReviewTable | SS4 | per-row error list, grouped, paged |
ErrorRowTile | SS4 | single failed row (mobile) |
ErrorGroupChip | SS4 | duplicates / references / format grouping |
ImportProgressPanel | SS4 | in-flight honesty panel (indeterminate) |
ExportCard | SS5 | entity picker + download |
2. FileDropZone
| Property | Value |
|---|---|
| Inputs | onFile(File), accept=".csv", maxRowsHint (1000), busy |
| States | idle / hover (highlight) / picking / parsing (spinner) / error (message) / disabled |
| Behaviour | native file picker on click; drag-drop on desktop; parse in isolate on selection (15 §4); strips UTF-8 BOM; detects non-UTF-8 → error guidance (14 §2) |
| a11y | exposed as a button with label "Choose CSV file"; drop target semantics; parse failure via live region |
| Motion | m-fast border glow on hover, shake on error (00-shared/08) |
3. ImportStepper
- Four steps: Upload · Preview · Confirm · Result. Current step filled; completed steps show check. Compact horizontal on desktop, top-of-card on phone. Steps are not independently clickable (wizard state lives in the cubit, 13); Back buttons inside each screen are the navigation.
4. CSVPreviewTable
| Property | Value |
|---|---|
| Data | headers (with per-header classification), first 10 rows, total rows |
| Row numbering | physical line numbers (header = 1) — must equal server rowNumber (bulk-import.service.ts:46) |
| Header cells | wrapped in ColumnChip; unknown columns muted |
| Whitespace flags | header with leading/trailing space → ContractAlert blocking (server does not normalize headers, 08 §1) |
| Phone | horizontal scroll, sticky first column (row #) |
| a11y | <table> semantics with th per column; row headers for # |
5. ColumnChip
| Variant | Colour (M3, 00-shared/02) | Meaning |
|---|---|---|
required | primary container | must be present; missing → blocking alert |
optional | secondaryContainer | rollNumber only (students-import.adapter.ts:25) |
unknown | surfaceVariant | ignored by server (parse is columns:true, unknown keys dropped — bulk-import.service.ts:26-30) |
6. ContractAlert
- Two severities:
- blocking — missing required column, whitespace-damaged header →
Nextdisabled; lists exact column names matching error stringsMissing required column "X".(students-import.adapter.ts:43). - warning — unknown columns ignored; > 1000 rows; > 2 MB.
- blocking — missing required column, whitespace-damaged header →
- Renders server-exact copy where it mirrors a server rejection (06 §2.2).
7. ImportSummaryCard
- Hero variants (per 06 §4.1): all-success (✓), partial (⚠), full-failure (✗), pre-row rejection (server 400/404).
- Counts with invariant check
imported + failed === totalRows(import-adapter.interface.ts:14-25); mismatch → data-error banner. - Sub-text links to server facts: notifications fanned out per row
(
event-queue-map.ts:10,28).
8. ErrorReviewTable / ErrorRowTile
- Columns:
Row(physical line),Fields(columns implicated),Error(full exact string, 08 §2), trailing actions (copy, download). - Grouping filter chips (
ErrorGroupChip): Duplicates · References · Format · Create-time (04 §7). - Pagination at 50 rows (client-side; no server pagination for reports —
the report is one object,
import-adapter.interface.ts:19-25). - Row tap → expand detail (mobile tile) or inline expansion (desktop); "go to file line N" hint (row number is the file line).
- Row number column pinned on horizontal scroll; zebra striping off for a11y contrast.
9. ImportProgressPanel (in-flight)
- Indeterminate bar + honest copy ("single request, no progress events",
bulk-import.service.ts:22-65). - Includes the timeout/partial-upload guidance (06 §4.1a).
- Seam for
(planned)async: the panel accepts aprogressvalue 0..1 and switches to determinate when a job API exists (13 §4).
10. ExportCard
- Entity selector (hard-coded
students,students-import.adapter.ts:15), format label CSV, roster-snapshot disclaimer (04 §6),Download CSVbutton →GET /bulk/export/:entity(bulk.controller.ts:50-60).
11. Global components consumed (from 00-shared/03)
AppButton (filled/outlined/text), AppCard, AppAlert, AppSnackbar,
AppChips, AppEmptyState, AppSkeleton, AppOfflineBanner, AppDialog
(confirm re-upload), AppMenu, AppFAB (SS5 → import), AppListTile,
AppBadge (error counts).
08 — Form Specifications (Bulk Module)
- 1. CSV column contract (import — students entity)
- 2. Error-string catalogue (exact)
- 3. Export "form" (SS5)
The only "form" in this module is the CSV itself plus the import confirm decision. This file specifies the CSV column contract field by field (from the adapter) and the complete error-string catalogue (exact strings from source). Every string below is quoted from code — the client must display these verbatim.
1. CSV column contract (import — students entity)
Source: students-import.adapter.ts:17-26 (columns array) + :38-144
(validation/resolution). Header names are exact, case-sensitive, no
normalization — the server parses with columns:true
(bulk-import.service.ts:26-30) which keys rows by the literal header.
1.1 Field-by-field
| # | Column | Required | Format / matching | Adapter source |
|---|---|---|---|---|
| 1 | firstName | yes | any non-empty string; → CreateUserDto.firstName (students-import.adapter.ts:66-70; create-user.dto.ts:5-8) | :18 |
| 2 | lastName | yes | any non-empty string; → CreateUserDto.lastName (create-user.dto.ts:15-17) | :19 |
| 3 | email | yes | must match /^[^\s@]+@[^\s@]+\.[^\s@]+$/ (students-import.adapter.ts:11,46-47); must not already be registered (:55-56 via UsersService.findByEmail) | :20 |
| 4 | admissionNumber | yes | any non-empty string; must not already exist (:49-54 via StudentRepository.findByAdmissionNumber) | :21 |
| 5 | grade | yes | matched against existing Grade by name OR code (:118-121, $or) | :22 |
| 6 | section | yes | matched against existing Section by name (:122-125) | :23 |
| 7 | academicYear | yes | matched against existing AcademicYear by name (:113-117) | :24 |
| 8 | rollNumber | no | blank → omitted (` |
1.2 Reference resolution rule (critical)
The adapter never creates academicYear/grade/section/class — they must
exist in the tenant before import. Class lookup is derived:
grade + section (+ academicYear) → classRepo.findOne (:131-142).
"Not found" rows are rejected, not auto-created (03 J2).
1.3 Value handling
- Cell values are trimmed by the parser (
trim:true,bulk-import.service.ts:29) and again in the adapter (:41-44); required check is!valueafter trim → empty string fails. - Row keys (headers) are not trimmed/normalized — a
" firstName "header fails every lookup (06 §2.2; 14 §2). - Extra/unknown columns are ignored (parser keeps them in the row object but
the adapter only reads its
columnskeys).
1.4 Template copy (client-generated (forward-looking))
| Row | Content |
|---|---|
| header | firstName,lastName,email,admissionNumber,grade,section,academicYear,rollNumber |
| example | Amara,Osei,amara.o@school.example,A-2026-001,Grade 7,B,2026-2027,01 |
Downloadable template is client-generated — there is no server template endpoint (04 §6).
2. Error-string catalogue (exact)
2.1 Request-level (file rejected, no rows touched)
| Trigger | Code | Exact string | Source |
|---|---|---|---|
multipart field file missing | 400 VALIDATION_ERROR | CSV file is required (multipart field "file"). | bulk.controller.ts:44-45 |
| CSV unparsable | 400 VALIDATION_ERROR | Malformed CSV: could not parse file. | bulk-import.service.ts:32 |
| zero data rows | 400 VALIDATION_ERROR | CSV must include a header row and data. | bulk-import.service.ts:35 |
| unknown entity | 404 RESOURCE_NOT_FOUND | No import adapter for entity "X". (X = path param) | bulk-import.service.ts:19 |
2.2 Row-level (per ImportRowError.errors[])
| Group | Exact string | Source |
|---|---|---|
| required missing | Missing required column "X". (X = column name) | students-import.adapter.ts:43 |
| format | Invalid email format. | :47 |
| duplicate | Admission number "X" already exists. | :54 |
| duplicate | Email "X" already registered. | :56 |
| reference | Academic year "X" not found. | :117 |
| reference | Grade "X" not found. | :121 |
| reference | Section "X" not found. | :125 |
| reference | No class found for grade "X" section "Y". | :140 |
| create-time | arbitrary message from the thrown Error (err.message) | bulk-import.service.ts:57-62 |
2.3 Client-side (pre-upload, mirroring server strings)
| Condition | Copy | Mirrors |
|---|---|---|
| unparsable file | This file could not be read as CSV. | bulk-import.service.ts:32 |
| header only / empty | CSV must include a header row and data. | :35 |
| missing required header | Missing required column "X". | students-import.adapter.ts:43 |
| whitespace-damaged header | Header " firstName" has leading/trailing space — the server will not match it. | :26-30 (headers unnormalized) |
| non-UTF-8 encoding | File is not UTF-8 — re-encode and retry. | bulk.controller.ts:47 |
3. Export "form" (SS5)
No input form: entity select → download. Columns produced
(students-import.adapter.ts:92-97): admissionNumber, rollNumber,
status, admissionDate (ISO string; '' when unset); rows sorted by
admissionNumber asc (:90-91). Export is a roster snapshot, not an import
template (04 §6).
09 — User Behaviour (Bulk Module)
- 1. Behavioural personas
- 2. Behavioural rules (derived from constraints)
- 3. Frequency & timing
- 4. Failure-response patterns (exact)
- 5. Habits the design must not fight
- 6. Metrics
(proposed)
Observed/expected user behaviour patterns that shape the bulk UX, derived from the workflow reality (admission-time onboarding, spreadsheet-driven data) and the backend's hard constraints. Analytics
(proposed).
1. Behavioural personas
| Pattern | Who | Impact on design |
|---|---|---|
| Batch-onboarding | Admissions Admin at admission season | wizard must be repeatable in < 2 min; report must be actionable (03 J1-J3) |
| Spreadsheet-first | Registrar hands over XLSX | client only accepts .csv; convert-in-app (forward-looking); clear guidance |
| Fix-and-reupload | Admin after partial failure | error CSV download + row-highlight re-upload (06 §4.1c) |
| Copy-paste admin | Admin who pastes from Excel into a text editor | BOM/CRLF/quote quirks — client sanitizes + warns (14 §2) |
2. Behavioural rules (derived from constraints)
- Expect duplicate rows on re-upload. Server rejects existing
emails/admissionNumbers per row (
students-import.adapter.ts:49-56); users habitually re-upload the same file. UI must label duplicates "already imported" instead of alarming (03 J3). - Expect "not found" reference rows on first try. New tenants import students before grades/sections/years are fully configured; the adapter refuses to create refs (08 §1.2). UI must link "Academic year not found" rows to the Academics setup screen and allow correcting the file, not the data.
- No one reads a 400-row error list linearly. Group errors (duplicates / references / format — 04 §7), page at 50, and offer error CSV download.
- Impatience with long uploads. The import is synchronous
(
bulk-import.service.ts:22-65); users will navigate away. The in-flight panel must (a) warn, (b) explain partial-upload safety (06 §4.1a), (c) auto-recover on return via a(forward-looking)resume/report check. - Template-first behaviour. Users want a template, not a spec. Provide
client-generated template download (08 §1.4) and treat
GET /bulk/exportas a roster snapshot, never as the template (04 §6). - Trust in counts. Users sum the report; the invariant
imported + failed === totalRows(04 §5) keeps the summary trustworthy — surface it verbatim. - Phone = review, desktop = do. File picking and error review happen on desktop; on phone the app should allow viewing reports and re-downloading error CSVs.
3. Frequency & timing
- Import: bursty (admission season, term start); export: weekly cadence.
- Sessions: < 5 min per batch; the whole wizard must fit in one sitting including one fix-reupload cycle.
4. Failure-response patterns (exact)
| User action | System response (server) | Expected user reaction | Designed response |
|---|---|---|---|
| Upload malformed file | 400 Malformed CSV: could not parse file. (bulk-import.service.ts:32) | frustration; checks file | client parse catches pre-upload; exact copy on SS4 |
| Upload header-only | 400 CSV must include a header row and data. (:35) | confusion | SS2 blocks with same string |
| Wrong entity | 404 No import adapter for entity "X". (:19) | surprise | client hard-codes entity list; entity picker (planned) discovery |
| Partial failure | report with row errors | fix-and-reupload | error CSV + row-highlight flow (03 J3) |
| Timeout/network | dropped request; rows may exist | re-upload fear | idempotency copy + duplicate labelling (06 §4.1a) |
| Rate limited | 429 (api tier 100/min) | wait | countdown copy, no auto-retry (00-shared/07 §4) |
5. Habits the design must not fight
- Users keep the source spreadsheet open while reviewing errors → row numbers
must match physical file lines (
bulk-import.service.ts:46). - Users rename/duplicate files → client keys the wizard state on the file hash + row count, not filename.
- Users paste email lists with stray commas → quoted-field parsing is
server-side (
csv-parsehandles it); client must use the same parser library in the isolate so preview == server (15 §4).
6. Metrics (proposed)
bulk.upload.sessions, bulk.upload.success_rate (imported/totalRows),
bulk.upload.first_pass_rate, bulk.result.time_to_reupload,
bulk.result.duplicate_share — the last two directly measure the
fix-and-reupload loop from 03 J3.
10 — Interaction Specification (Bulk Module)
- 1. SS1 — Upload interactions
- 2. SS2 — Preview interactions
- 3. SS3 — Confirm interactions
- 4. SS4 — Result interactions
- 5. SS5 — Export interactions
- 6. The long-running import interaction (deep treatment)
- 7. Motion & micro-feedback
- 8. Keyboard / focus
Precise interaction rules per screen and the long-running import interaction. Motion tokens per 00-shared/08; components in 07.
1. SS1 — Upload interactions
| # | Interaction | Rule |
|---|---|---|
| 1.1 | File pick | FileDropZone click/drop; .csv filter; immediately isolate-parse (15 §4) |
| 1.2 | Parse fail | inline AppAlert; file card shows error; Next disabled; live-region announce |
| 1.3 | Parse success | file card confirms (name, rows, headers); Next enabled; m-fast check-in |
| 1.4 | Non-UTF-8 / BOM | BOM stripped silently; non-UTF-8 → warning card with re-encode guidance (14 §2) |
| 1.5 | Re-pick | replaces previous file; wizard state (preview/confirm) reset to preview |
2. SS2 — Preview interactions
| # | Interaction | Rule |
|---|---|---|
| 2.1 | Header classification | ColumnChip per header (required/optional/unknown) |
| 2.2 | Blocking warning | ContractAlert blocking; Import N rows disabled; lists exact missing column names (08 §1) |
| 2.3 | Sample rows | first 10; row # = physical line (header=1) |
| 2.4 | Back | returns to SS1 with file retained |
| 2.5 | Unknown columns | warning only; never blocks (server ignores, bulk-import.service.ts:26-30) |
3. SS3 — Confirm interactions
| # | Interaction | Rule |
|---|---|---|
| 3.1 | Start import | fires POST /bulk/import/students (multipart field file, bulk.controller.ts:35-48); navigates immediately to SS4 in-flight; CTA disabled to block double-fire |
| 3.2 | Back | allowed until fired; no server side effects before firing |
4. SS4 — Result interactions
| # | Interaction | Rule |
|---|---|---|
| 4.1 | In-flight | ImportProgressPanel indeterminate; no progress events exist (bulk-import.service.ts:22-65); panel explains and warns |
| 4.2 | Success | summary card; View students; Download report |
| 4.3 | Partial | grouped error table; pagination 50; row tap → expand detail |
| 4.4 | Row action | copy error string; per-row "open file at line N" hint (rowNumber = line, :46) |
| 4.5 | Error CSV | client-synthesized: original row + error column; no server endpoint |
| 4.6 | Fix errors | → SS1 with failed row numbers queued; preview highlights them when the corrected file parses (03 J3) |
| 4.7 | Pre-row server rejection | 400/404 rendered as failure card with exact copy (08 §2.1) |
| 4.8 | 429 | countdown Retry in Ns, no auto-retry (00-shared/07 §4) |
| 4.9 | Network drop | partial-upload guidance; re-upload is duplicate-safe (06 §4.1a) |
5. SS5 — Export interactions
| # | Interaction | Rule |
|---|---|---|
| 5.1 | Download | GET /bulk/export/students (bulk.controller.ts:50-60); browser attachment students.csv; loading state on button |
| 5.2 | Empty | header-only CSV still downloads; toast "No rows exported" |
| 5.3 | Export ≠ template | disclaimer on card (04 §6) |
6. The long-running import interaction (deep treatment)
The one request-holding interaction is import execution, and everything about it must be honest about the synchronous backend:
| Aspect | Design |
|---|---|
| Duration reality | ~1000 rows × (2 dup checks + 3-4 ref lookups + 2-3 writes) of sequential awaits inside one request (bulk-import.service.ts:45-63; students-import.adapter.ts:38-85) |
| Feedback | indeterminate progress + copy: "validated and created server-side in one request; keep this tab open" |
| Duration guard | client request timeout ≥ 120 s (configurable); on timeout → partial-upload guidance (4.9) |
| File cap | 1000 rows / ~2 MB at pick time; warning above, block above hard cap (configurable (forward-looking)) |
| Rate budget | one import can be a large chunk of the 100/min api tier (00-shared/07 §4) — warn on very large files |
| Cancel | no server cancel exists; cancel only abandons the response — copy states rows may still be created |
| Future seam | when async workers land ((planned), IMPLEMENTATION_PLAN.md:172), the panel switches to determinate + polling; cubit keeps progress + jobId fields ready (13 §4) |
7. Motion & micro-feedback
| Moment | Motion (00-shared/08) |
|---|---|
| file accepted | m-fast (check icon pop) |
| parse error / block | m-fast shake + live-region |
| wizard step advance | m-base slide, stepper check pop |
| result counts | m-base count-up on imported/failed |
| error row expand | m-fast height collapse |
8. Keyboard / focus
- SS1 entity select + browse: full keyboard path; drop zone reachable via Tab
- Enter.
- SS2/SS4 tables: row # column is focusable; row actions Tab-able.
- SS3: single CTA, Enter fires; Back via Escape.
- No text inputs exist anywhere in the wizard except the
(forward-looking)entity search — keyboard-avoidance trivial.
11 — Design System Mapping (Bulk Module)
- 1. Token usage (00-shared/02)
- 2. Density & layout rules
- 3. Status colour mapping (module-specific)
- 4. Typography & copy tone
- 5. Motion mapping (00-shared/08)
- 6. Accessibility mapping (00-shared/09)
- 7. Error & empty states (00-shared/03)
- 8. Mapping gaps (flagged for the shared system)
How the bulk module maps onto the shared design system: tokens (00-shared/02), components (00-shared/03, 07), motion (00-shared/08), a11y (00-shared/09), QA baseline (00-shared/10). Material 3 tokens are proposed defaults until a designer finalizes them (00-shared/01 §9).
1. Token usage (00-shared/02)
| Token | Where | Rationale |
|---|---|---|
color.primary (filled) | SS3 Start import, SS1 Next, SS5 Download | single primary action per screen |
color.error | blocking alerts, error rows, failure cards | severity semantics only |
color.warning (amber) | unknown-header chips, size warnings | non-blocking attention |
color.secondaryContainer | optional ColumnChip (rollNumber) | neutral secondary |
color.surfaceVariant | unknown ColumnChip | muted, ignorable |
color.success (M3 extended, 00-shared/02 §4) | all-success summary, stepper checks | status, not CTA |
spacing grid 4/8/12/16/24 | card padding 24, table cell 12, chip gap 8 | consistency |
radius.md (12) | cards, drop zone, tables | forms & data density |
type.titleMedium | screen titles, summary numbers | hierarchy |
type.bodyMedium | table cells; type.labelLarge for row # column | data tables |
2. Density & layout rules
- Data-heavy surfaces (SS2 preview, SS4 errors) use compact density:
12 dp cell padding,
type.bodyMedium, monospace for row numbers (font.family.monoif defined in 00-shared/02, else default). - Wizard cards constrained to ≤ 720 dp (SS1/SS3); tables expand to full width.
- Phone: tables scroll horizontally with the row-# column pinned; summary cards stack.
- Zebra striping off; separation by 1 dp
outlineVariantrow dividers (a11y contrast, 00-shared/09 §3).
3. Status colour mapping (module-specific)
| Status | Colour | Surface |
|---|---|---|
imported count | color.success | ImportSummaryCard |
failed count | color.error | ImportSummaryCard |
| in-flight | color.primary indeterminate bar | ImportProgressPanel |
| duplicate error group | color.warning chip | ErrorGroupChip |
| reference-not-found group | color.primary chip | ErrorGroupChip |
| format / create-time group | color.error chip | ErrorGroupChip |
4. Typography & copy tone
- Numbers (counts, row #) rendered in tabular figures to avoid jitter during count-up (10 §7).
- Copy rules: always pair a number with its meaning ("15 failed", not "15"); error strings displayed verbatim from the server (08 §2); honesty required about sync import (06 §4.1a).
- No emoji in UI strings; icons from the shared set (check, warning, download, upload, table).
5. Motion mapping (00-shared/08)
| Token | Use |
|---|---|
m-fast (120-160 ms) | file accept, error shake, chip state, row expand |
m-base (200-260 ms) | wizard step transition, stepper check, count-up, panel swap |
easing emphasized | transitions; standard for data rows |
6. Accessibility mapping (00-shared/09)
| Requirement | Bulk implementation |
|---|---|
| Live regions | parse failures (SS1), result summary (SS4), contract blocking (SS2) |
| Focus order | drop zone → entity → Next; table cells focusable; CTA Enter |
| Contrast | error/warning text on surface ≥ 4.5:1; chips use containers not outlines |
| Screen-reader labels | tables: real th; stepper: aria-current-equivalent semantics; summary read as one sentence |
| Touch targets | ≥ 48 dp on phone (row actions as tiles, ErrorRowTile) |
| Reduced motion | respect motion.reduced — replace count-up/shake with static states (00-shared/08 §6) |
| Keyboard only | full wizard usable without pointer (10 §8) |
7. Error & empty states (00-shared/03)
| State | Pattern |
|---|---|
| Permission | AppEmptyState + "contact admin" (client gate; server RBAC (planned)) |
| Offline | AppOfflineBanner; import/export blocked (no offline writes, 00-shared/06 §3.5) |
| Rate limited | countdown copy, no auto-retry (00-shared/07 §4) |
| Empty roster export | header-only CSV + toast (06 §5.2) |
| All-failed import | failure card + "fix errors" primary |
8. Mapping gaps (flagged for the shared system)
- No table component in 00-shared/03 —
CSVPreviewTable/ErrorReviewTable(07) become the reference implementation; propose them for the shared library. - No file drop-zone in 00-shared/03 —
FileDropZone(07) is the reference; same forImportStepper. - Material 3 tokens are proposed defaults (00-shared/01 §9) — bulk data tables should be validated against the final token set when it lands.
12 — API Mapping (Bulk Module)
- E1 — Import entity CSV (the module's core endpoint)
- E2 — Export entity CSV
- E3 — Related endpoints (import touchpoints)
- E4 — Permission & rate-limit posture
- E5 — Event fan-out (exact)
- Client contract summary
Exact endpoints per screen. Wire contract per 00-shared/07: base
/api/v1, Bearer JWT, success{success:true,message:"OK",data,meta?, timestamp,requestId}, error envelope with codes. Only shapes in code are used.(planned)/(forward-looking)marked. Sources:bulk.controller.ts,bulk-import.service.ts,import-adapter.interface.ts,students-import.adapter.ts,permissions.constants.ts,event-queue-map.ts,queue.constants.ts.
E1 — Import entity CSV (the module's core endpoint)
| Endpoint | POST /api/v1/bulk/import/:entity (bulk.controller.ts:35-48) |
| Guard | JwtAuthGuard (bulk.controller.ts:30); RBAC permission guard (planned) — user.import exists (permissions.constants.ts:10) but is not enforced on this route |
| Content | multipart/form-data, field file (FileInterceptor('file') :38); required — missing → 400 VALIDATION_ERROR CSV file is required (multipart field "file"). (:43-46) |
| Entity param | free string; resolved by adapterFor (bulk-import.service.ts:17-20); students only (students-import.adapter.ts:15); anything else → 404 RESOURCE_NOT_FOUND No import adapter for entity "X". (bulk-import.service.ts:19); other entities (planned) (bulk-import.service.ts:15-16, IMPLEMENTATION_PLAN.md:172) |
| Encoding | file buffer decoded UTF-8 server-side (bulk.controller.ts:47) |
| Parse | csv-parse/sync {columns:true, skip_empty_lines:true, trim:true} (bulk-import.service.ts:26-30); unparsable → 400 VALIDATION_ERROR Malformed CSV: could not parse file. (:31-33); zero data rows → 400 VALIDATION_ERROR CSV must include a header row and data. (:34-35) |
| Execution | synchronous per-row validate → create loop (:45-63); rowNumber = physical line (header = 1) (:46); one bad row never aborts the batch (bulk-import.service.spec.ts:53-65) |
| Response | 200 envelope; data = ImportReport {entity, totalRows, imported, failed, errors: [{rowNumber, errors[]}]} (import-adapter.interface.ts:14-25); invariant imported + failed === totalRows |
| Errors | 400 (missing file, malformed, empty — bulk.controller.ts:43-46, bulk-import.service.ts:31-35); 404 (unknown entity :19); 429 RATE_LIMITED (api tier 100/min, 00-shared/07 §4); 5xx |
| Row semantics (students) | per row: dup checks (admissionNumber via studentRepo.findByAdmissionNumber :49-54; email via usersService.findByEmail :55-56); ref resolution (academicYear by name :113-117, grade by name-or-code :118-121, section by name :122-125, class by grade+section+year :131-142) → validate errors; else usersService.create (:66-71) + studentService.create (:76-84) |
| Side effects | UserCreated event → in-app queue user-created-notification (users.service.ts:64-76; event-queue-map.ts:10); StudentCreated event → in-app queue student-enrolled (student.service.ts:79-91; event-queue-map.ts:28); student auto-enrolled into class enrollment, status ACTIVE, admissionDate now (student.service.ts:63-78) |
| Race fallback | if usersService.create throws (duplicate), the adapter reuses the existing user id (students-import.adapter.ts:72-75) — row still imports as a student |
| Async/polling | none exists — result returned in the same request; no bulk queue in queue.constants.ts:1-17; async workers with BullMQ progress + rollback (planned) (IMPLEMENTATION_PLAN.md:172); students-specific POST /api/v1/students/bulk-import (planned) (IMPLEMENTATION_PLAN.md:195) |
| Client | SS1-SS4 wizard; in-flight = indeterminate (10 §6); timeout → partial-upload guidance (06 §4.1a) |
E2 — Export entity CSV
| Endpoint | GET /api/v1/bulk/export/:entity (bulk.controller.ts:50-60) |
| Guard | JwtAuthGuard (:30); RBAC (planned) |
| Response | text/csv (:52); Content-Disposition: attachment; filename="<entity>.csv" (:55-58); body = csv-stringify of adapter rows with header (bulk-import.service.ts:67-71) |
| Students rows | admissionNumber, rollNumber, status, admissionDate (ISO string, '' when unset), sorted by admissionNumber asc (students-import.adapter.ts:87-98) |
| Empty | header-only CSV (stringify([],{header:true})) — still 200 |
| Errors | 404 unknown entity (bulk-import.service.ts:19); 429; 5xx |
| Client | SS5; roster snapshot ≠ import template (04 §6) |
E3 — Related endpoints (import touchpoints)
| Endpoint | Purpose | Source |
|---|---|---|
POST /api/v1/users/import | separate inline import engine (naive split(','); {imported, errors[]}; no events) — not part of this module | users.controller.ts:104-110; users.service.ts:233-282 |
POST /api/v1/students/bulk-import | (planned) async students import | IMPLEMENTATION_PLAN.md:195 |
POST /api/v1/files/upload-csv → worker | (planned) queue-based CSV → per-row UserCreated | IMPLEMENTATION_PLAN.md 2.7 (users package 01 §4) |
GET /api/v1/students | post-import verification for SS4 "View students" | students module |
E4 — Permission & rate-limit posture
| Concern | Fact |
|---|---|
| Route guard | JwtAuthGuard only (bulk.controller.ts:30) |
user.import | defined (permissions.constants.ts:10); not bound to the bulk routes — RBAC enforcement (planned) |
student.create / user.create | exist (:7,26); same unbound status on these routes |
student.import | does not exist — intended import permission is user.import |
| Rate limit | api tier 100/min (00-shared/07 §4); one large import consumes a large share — client warns (10 §6) |
E5 — Event fan-out (exact)
| Event | Emitter | Route (event-queue-map.ts) |
|---|---|---|
UserCreated | UsersService.create per imported row (users.service.ts:64-76) | in-app / user-created-notification (:10) |
StudentCreated | StudentService.create per imported row (student.service.ts:79-91) | in-app / student-enrolled (:28) |
Contrast: the users module's inline import (users.service.ts:264-272) emits
no events; the bulk path emits both per row. Imported users get in-app
notifications; there is no email for them (email worker handles only
UserRegistered / PasswordResetRequested — users package 01 §4).
Client contract summary
| Concern | Rule |
|---|---|
| Auth | Bearer JWT; 401 → single-flight refresh → replay; fail → session expiry (00-shared/06 §3.6) |
| Upload | multipart field file; content-type multipart/form-data; never optimistic |
| Idempotency | re-upload safe via per-row dedup (students-import.adapter.ts:49-56); no Idempotency-Key needed |
| Offline | reads from last-good cache + banner; import/export blocked (00-shared/06 §3.5) |
| Error mapping | 00-shared/06 §5: 400 file-level, 404 entity, 429 backoff, 5xx generic + requestId; row-level errors live in the 200 body, not the envelope |
| Realtime | none for bulk; (forward-looking) completion topic when async lands (06 §4.3) |
13 — State Management (Bulk Module)
- 1. Cubit map
- 2. BulkHomeCubit (SS1)
- 3. BulkPreviewCubit (SS2)
- 4. BulkImportCubit — the import state machine (SS3/SS4)
- 5. BulkExportCubit (SS5)
- 6. Persistence & navigation
Per-screen state on top of 00-shared/06_State_Management.md (Bloc/Cubit proposal). Cubits are
(planned); repository/API facts are exact. Special attention: the synchronous import state machine (§4).
1. Cubit map
BulkHomeCubit → SS1 (entity, file, client parse)
BulkPreviewCubit → SS2 (headers, warnings, sample rows)
BulkImportCubit → SS3/SS4 (owns the import state machine, §4)
BulkExportCubit → SS5
SessionCubit → shared (self id, permission gate)
2. BulkHomeCubit (SS1)
- State:
LoadState+entities: ['students'](hard-coded client mirror ofstudents-import.adapter.ts:15;(planned)discovery),selectedEntity,fileMeta? {name, size, rows, hash},parseStatus: idle|parsing|ok|error,parseError?. - Events:
SelectEntity,PickFile(File)→ isolate parse (15 §4) with options mirrored from the server (columns:true, skip_empty_lines:true, trim:true—bulk-import.service.ts:26-30),RetryParse. - Rules:
Nextenabled ⇔parseStatus == ok && rows > 0(mirrors 400CSV must include a header row and data.—:34-35); file hash replaces wizard state (10 §1.5).
3. BulkPreviewCubit (SS2)
- State: headers with classification (
required | optional | unknownper08 §1),blocking: string[](missing required columns — names matchMissing required column "X".,students-import.adapter.ts:43),warnings: string[](unknown columns, size, whitespace headers),sampleRows(first 10, physical line numbers). - Events:
Next(→ confirm),Back(file retained). - Pure computation, no server calls — the preview must agree with the server
parser by construction (same library + options in the isolate,
15 §4).
4. BulkImportCubit — the import state machine (SS3/SS4)
The backend is synchronous (bulk-import.service.ts:22-65); the cubit
models the request lifecycle and keeps a (planned) async seam.
4.1 States
Idle → Confirming → InFlight → Done { AllSuccess | Partial | FailedPreRow }
└→ FixAndReupload → (SS1) → …
4.2 Mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Confirming: confirmReady (SS3)
Confirming --> InFlight: startImport (POST /bulk/import/students)
InFlight --> AllSuccess: 200 & imported == totalRows
InFlight --> Partial: 200 & failed > 0
InFlight --> FailedPreRow: 400/404 (malformed | empty | unknown entity)
InFlight --> FailedPreRow: 429 | 5xx | network drop
AllSuccess --> [*]
Partial --> FixAndReupload: fix errors
FailedPreRow --> FixAndReupload
FixAndReupload --> Confirming: corrected file re-picked
Partial --> Idle: view students (exit)
state "async seam (planned)" as AS {
InFlight --> JobQueued: jobId returned
JobQueued --> Polling: GET /bulk/import/:id (planned)
Polling --> AllSuccess
Polling --> Partial
}
4.3 State shape
| Field | Meaning |
|---|---|
phase | idle | confirming | inFlight | done |
outcome | allSuccess | partial | failedPreRow |
report? | ImportReport {entity,totalRows,imported,failed,errors[{rowNumber,errors[]}]} (import-adapter.interface.ts:14-25) — verbatim from data |
requestState | sent | timedOut — timeout ≠ failure: rows may exist (06 §4.1a) |
jobId?, progress? | (planned) async seam — unused today (no queue, queue.constants.ts:1-17) |
failedRowsQueue | row numbers carried to SS1 for re-upload highlighting (03 J3) |
4.4 Transitions (exact)
| From | Event | To | Condition / source |
|---|---|---|---|
| Confirming | startImport | InFlight | multipart file field (bulk.controller.ts:38); CTA double-fire blocked |
| InFlight | importSucceeded | AllSuccess | 200, imported === totalRows |
| InFlight | importPartial | Partial | 200, failed > 0 |
| InFlight | importRejected | FailedPreRow | 400 Malformed CSV: could not parse file. (:32) / CSV must include a header row and data. (:35); 404 No import adapter for entity "X". (:19) |
| InFlight | importRejected | FailedPreRow | 429 (countdown copy) / 5xx / network drop (partial-upload guidance) |
| Partial / FailedPreRow | fixErrors | FixAndReupload | → SS1 with failedRowsQueue |
4.5 Client-side report assertions
imported + failed === totalRows— violated →dataMismatchflag renders the raw report with a support banner (06 §4.2).- Group errors (04 §7) by string prefix — grouping is derived, never hard-coded against future adapter messages.
5. BulkExportCubit (SS5)
- State:
entities(hard-codedstudents),downloading,lastStatus(ok | empty | error). - Events:
Download(entity)→GET /api/v1/bulk/export/:entity(bulk.controller.ts:50-60); save via browser attachment (filename="<entity>.csv",:55-58).
6. Persistence & navigation
- Wizard state lives in the cubits, not route args (file objects can't serialize); on app restart mid-import → fresh start + guidance (09 §4).
- No cache layer needed (stateless endpoints); export result is the file itself.
- Optimistic updates: none — imports/exports are never optimistic
(
00-shared/06 §3.4).
14 — QA Checklist (Bulk Module)
- 1. Functional core
- 2. File-level robustness (quoted commas, BOM, encoding, whitespace)
- 3. Validation matrix (per-row, exact strings in 08 §2)
- 4. Duplicates & idempotent re-upload
- 5. Row limits & large files (1000 rows)
- 6. Concurrency
- 7. Entity & error envelope
- 8. Export
- 9. Documentation drift (flagged)
- 10. Client-side (forward-looking)
Acceptance + regression checklist for the bulk import/export surface. Every item is traceable to source. Run against a seeded tenant (grades/sections/ academic years/classes exist) per 00-shared/10_QA_Baseline.md.
1. Functional core
-
POST /api/v1/bulk/import/studentswith a valid file returns 200 and a report whereimported + failed === totalRows(import-adapter.interface.ts:14-25). -
rowNumberof the first data row is 2 (header = row 1,bulk-import.service.ts:46) — matches the physical file line. -
Valid row creates: user (name/email), student (admissionNumber,
rollNumber), class enrollment, status
ACTIVE, admissionDate now (students-import.adapter.ts:62-85;student.service.ts:63-78). -
Valid row fans out
UserCreated→ in-appuser-created-notificationandStudentCreated→ in-appstudent-enrolled(event-queue-map.ts:10,28). -
One bad row does not abort the batch (
bulk-import.service.spec.ts:53-65).
2. File-level robustness (quoted commas, BOM, encoding, whitespace)
-
Quoted commas / quotes:
email,"last, Name Jr.",…—csv-parsehandles RFC-4180 quoting (bulk-import.service.ts:26-30); verify a field containing a comma inside quotes imports intact; unclosed quote → 400Malformed CSV: could not parse file.(:31-33). -
BOM: UTF-8 BOM (
EF BB BF) — client strips it in the isolate (15 §4); verify direct API call with BOM still parses (csv-parse tolerates it) and that preview == server result. -
Encoding: UTF-8 only (
bulk.controller.ts:47). Windows-1252 accents → client warning "re-encode as UTF-8"; never send mojibake. - CRLF vs LF: both accepted (parser handles); verify mixed line endings.
-
Header whitespace: header
" firstName"fails required check withMissing required column "firstName".(students-import.adapter.ts:43) — client must block pre-upload (06 §2.2); server behaviour is "row errors", not a crash. -
Empty lines: skipped (
skip_empty_lines:true,:28); header-only file → 400CSV must include a header row and data.(:34-35). -
Trailing empty cells / stray columns: unknown columns ignored;
blank optional
rollNumber→ omitted (:79).
3. Validation matrix (per-row, exact strings in 08 §2)
-
Missing each required column →
Missing required column "X". -
Bad email →
Invalid email format. -
Existing admissionNumber →
Admission number "X" already exists. -
Existing email →
Email "X" already registered. -
Unknown academicYear / grade / section →
… not found. -
Grade matched by code too (name OR code,
:118-121). -
Known grade+section but no class →
No class found for grade "X" section "Y". - No row with an unresolved ref is ever created.
4. Duplicates & idempotent re-upload
- Same file uploaded twice: second run reports every row as duplicate; zero new users/students; counts consistent.
-
Race fallback: concurrent create of same email → second row reuses the
existing user id and still creates the student (
students-import.adapter.ts:72-75). - Duplicate email + different admissionNumber: email row rejected, student not created.
- Duplicate admissionNumber + different email: same rejection semantics.
5. Row limits & large files (1000 rows)
-
1000-row file: all rows processed; report complete; no partial
response; response time is the long-pole (sequential awaits,
bulk-import.service.ts:45-63). - Client caps pick at 1000 rows / ~2 MB (10 §6) — verify warning copy and hard block.
- Mid-request network drop: client shows partial-upload guidance; a follow-up import reports completed rows as duplicates (06 §4.1a).
- Rate budget: a large import against the 100/min api tier (00-shared/07 §4) — verify 429 handling on rapid successive uploads.
6. Concurrency
-
Two simultaneous imports from different users: no cross-tenant bleed
(all repos tenant-scoped via
BaseRepository); duplicates handled per row by validation + race fallback. - Import while grades/classes are being edited: worst case = "not found" row errors, never corruption.
-
Import is not transactional (no rollback): partial success is the
designed behaviour — report reflects exactly what was created
(
bulk-import.service.spec.ts:53-65).
7. Entity & error envelope
-
POST /bulk/import/fees→ 404No import adapter for entity "fees".(bulk-import.service.ts:19); client never offers it (onlystudents,students-import.adapter.ts:15); others(planned)(IMPLEMENTATION_PLAN.md:172). -
Missing multipart file → 400 exact string (
bulk.controller.ts:43-46). - Unauthenticated → 401; no tenantId from body ever accepted (00-shared/07 §6).
8. Export
-
GET /bulk/export/students→Content-Type: text/csv,Content-Disposition: attachment; filename="students.csv"(bulk.controller.ts:50-60). -
Columns
admissionNumber, rollNumber, status, admissionDatein order; sorted by admissionNumber asc (students-import.adapter.ts:87-98); dates ISO; empty roster → header-only CSV (bulk-import.service.ts:67-71). - Export round-trip sanity: importing the exported roster fails as duplicates (export ≠ template — 04 §6) — expected, documented.
9. Documentation drift (flagged)
-
Shared ledger glossary claims imports are "processed asynchronously by
workers" (00-shared/01 §10) — code is synchronous; this doc set
is the corrected reference until async lands
(planned)(IMPLEMENTATION_PLAN.md:172). Add a red-line note when the async work ships.
10. Client-side (forward-looking)
- Preview and server agree: same parser + options in the isolate (15 §4) — test one file through both paths, results identical.
- Error strings displayed verbatim; row numbers match file lines.
- 429 countdown, offline banner, permission gate, a11y live regions (10 §8, 11 §6).
-
Analytics events fire
(proposed).
15 — Flutter Implementation Guide (Bulk Module)
- 1. Module structure
- 2. BulkRepository
- 3. File picker
- 4. Isolate CSV parse (deep treatment)
- 5. Progress UI (deep treatment)
- 6. Result rendering & error CSV
- 7. Export
- 8. Offline, permissions, analytics
- 9. Tests (forward-looking,
00-shared/10)
Module implementation on top of 00-shared/11_Flutter_App_Architecture.md. Forward-looking (no client repo). Backend constraints cited; isolate CSV parse, progress UI, and file picker get the deep treatment as required.
1. Module structure
lib/features/bulk/
├── data/
│ ├── dto/import_report_dto.dart # {entity,totalRows,imported,failed,errors[{rowNumber,errors[]}]}
│ ├── dto/import_row_error_dto.dart # {rowNumber, errors[]}
│ ├── models/import_report.dart # normalized + invariant assert
│ ├── models/csv_file.dart # name, size, hash, rows (parsed in isolate)
│ └── repositories/
│ └── bulk_repository.dart # importCsv(entity, file) + exportCsv(entity)
├── domain/
│ ├── csv_parse_service.dart # isolate wrapper, mirrors server options
│ └── csv_error_exporter.dart # original row + error column → CSV
└── presentation/
├── cubit/ (bulk_home, bulk_preview, bulk_import, bulk_export)
├── pages/ (bulk_home_page, bulk_preview_page, bulk_confirm_page,
│ bulk_result_page, bulk_export_page)
└── widgets/ (file_drop_zone, import_stepper, csv_preview_table,
column_chip, import_summary_card, error_review_table,
error_row_tile, import_progress_panel)
DTO→model mapping per 00-shared/11 §4 (json_serializable); the report DTO
mirrors import-adapter.interface.ts:14-25 exactly — unknown fields ignored,
errors default [] (forward-compat, 07 §3).
2. BulkRepository
importCsv(String entity, Uint8List bytes)→MultipartRequestPOST /api/v1/bulk/import/:entity, fieldfile(filenamestudents.csv,contentType: text/csv) — matchesFileInterceptor('file')(bulk.controller.ts:38-48); body decoded UTF-8 server-side (:47). ReturnsImportReportfrom envelopedata.exportCsv(String entity)→GET /api/v1/bulk/export/:entity(bulk.controller.ts:50-60); returns bytes for download/share; on web use anchor download with theContent-Dispositionfilename.- Error mapping (
00-shared/06 §5): 400 file-level (malformed/empty), 404 entity, 429 countdown, 5xx generic + requestId; row errors live in the 200 body, not the envelope.
3. File picker
| Platform | Package / API | Notes |
|---|---|---|
| web/desktop | file_picker (FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['csv'])) | also universal_html drag-drop for the drop zone |
| Android/iOS | file_selector/file_picker with mime text/csv, text/comma-separated-values, application/csv | some providers return .txt for CSV — accept by content sniff, not extension alone |
| All | read as Uint8List; never as decoded string (encoding detection needs raw bytes) |
- Size guard before parse: reject > 2 MB with warning copy (10 §6); row-cap check happens after parse (1000 rows).
- BOM strip: if bytes start
EF BB BF, drop the prefix before parsing (14 §2).
4. Isolate CSV parse (deep treatment)
The client preview must agree with the server by construction: same
parser, same options as bulk-import.service.ts:26-30
(columns:true, skip_empty_lines:true, trim:true).
- Same library: use the Dart port of the same CSV spec —
csvpackage withshouldParseNumbers: false(never let the parser coerce types; server rows areRecord<string, string>,bulk-import.service.ts:24). - Isolate: parsing 1000+ rows + header mapping must never jank the UI:
Future<CsvParseResult> parseInIsolate(Uint8List bytes) async {
final result = await compute(_parseWorker, bytes, debugLabel: 'bulk-csv');
if (result.error != null) throw CsvParseException(result.error!);
return result;
}
CsvParseResult _parseWorker(Uint8List bytes) {
try {
final text = utf8.decode(bytes); // BOM stripped upstream
final rows = const CsvToListConverter(
shouldParseNumbers: false,
eol: '\n',
allowInvalid: false,
).convert(text);
// mirror csv-parse options:
// columns:true → first row = headers
// trim:true → trim cell values
// skip_empty_lines:true → drop blank lines
// then reject: unclosed quote / ragged rows → CsvParseException
...
} catch (e) {
return CsvParseResult(error: 'Malformed CSV: could not parse file.');
}
}
- Isolate mirror table (server vs client):
csv-parse option (bulk-import.service.ts:26-30) | Dart csv equivalent |
|---|---|
columns: true | first row = header; rows → Map<String,String> |
trim: true | trim each cell after split |
skip_empty_lines: true | drop blank lines before mapping |
| quoting (RFC-4180) | default csv behaviour — must match; test quoted commas (14 §2) |
| header normalization | none on server — keep keys verbatim; flag whitespace headers (06 §2.2) |
- Result of the isolate:
CsvParseResult {headers[], rows: Map<String,String>[], physicalRowNumbers[], error?}. Physical row numbers are the file line numbers (header = 1) so the preview and the server report agree (bulk-import.service.ts:46). - File hash (
package:cross_file/sha-256 of bytes) keys the wizard state and enables "re-pick same file" detection (10 §1.5).
5. Progress UI (deep treatment)
The backend is synchronous — there are no progress events
(bulk-import.service.ts:22-65). The honest UI is an indeterminate
LinearProgressIndicator on ImportProgressPanel (07 §9):
- Navigate to SS4 before awaiting the request (10 §3.1); the cubit owns
the
Future. - Copy per
06 §4.1a: "validated and created server-side in one request — keep this tab open". - Request timeout ≥ 120 s; on timeout set
requestState: timedOut→ partial-upload guidance (rows may exist; re-upload is duplicate-safe,students-import.adapter.ts:49-56). - Async seam
(planned): whenIMPLEMENTATION_PLAN.md:172workers land (queue + progress + polling),ImportProgressPanelacceptsprogress: 0..1and the cubit addsjobId/polling(13 §4.2). Keep the panel's API(panel.progress?, phase)so the swap is a field, not a rebuild. - Row-level feedback beyond the report is impossible today — no partial reports, no resume (14 §5).
6. Result rendering & error CSV
ImportSummaryCardassertsimported + failed === totalRows; mismatch → data-error banner with raw report (06 §4.2).- Error groups derived by string prefix per
04 §7(duplicates / references / format) — grouping logic keyed on the exact strings in08 §2.2. - Error CSV download is client-synthesized: original file rows + appended
errorcolumn joined from the report byrowNumber. No server endpoint exists. - "Fix errors" passes
failedRowsQueue(row numbers) back to SS1 for highlighting after re-pick (03 J3).
7. Export
exportCsvvia repository; on mobile hand bytes to a share sheet (share_plus); on web trigger an anchor download from the response bytes withfilename="students.csv"(bulk.controller.ts:55-58).- Empty roster → still download header-only CSV (06 §5.2).
8. Offline, permissions, analytics
- No offline writes: import/export require connectivity
(
00-shared/06 §3.5); reads none in this module. - Permission gate: client-side admin check today; handle 403 gracefully once
the server RBAC guard lands
(planned)(permissions.constants.ts:10). - Analytics
(proposed):bulk.upload.pick,bulk.preview.warnings,bulk.confirm.start(n),bulk.result.view(imported,failed),bulk.result.reupload,bulk.export.download.
9. Tests (forward-looking, 00-shared/10)
- Isolate parser vs server spec table (quoted commas, BOM, CRLF, empty lines, trim, whitespace headers — 14 §2/§3).
- Report DTO decode incl. invariant violation path.
- Cubit state machine transitions (13 §4.4) incl. timeout → timedOut.
- Golden: SS4 partial-success rendering with a fixture report.
01 — Product Overview (Communication Module)
- 1. Purpose
- 2. PRD / blueprint scope note (native-app exclusion)
- 3. Business goals
- 4. User goals
- 5. Why this exists
- 6. Module status matrix
- 7. Platform dependencies (backend)
- 8. Out of scope for this package
StudyLyon — Multi-Tenant ERP & School Management Platform. This package specifies the client-side (Flutter) design for the Communication module against the existing NestJS backend (
src/modules/communication/). Backend is the source of truth for routes, contracts, and behavior; shared foundations live in00-shared/.
1. Purpose
The Communication module lets an institution broadcast announcements to a chosen audience and run lightweight 1:1/group messaging (threads + messages). Announcements are the primary surface: admin/staff compose, publish, and measure read receipts; recipients (students, parents, teachers, staff) read them in a feed.
Backend scope today (docs/IMPLEMENTATION_PLAN.md:85-102, Phase 2): controllers
announcements, messages, threads; schemas announcement, message, thread; plus
events and repositories.
2. PRD / blueprint scope note (native-app exclusion)
The PRD blueprint (studylyon-blueprint/) targets a web dashboard; it defines no
native mobile client. There is no Communication/Announcements doc in
studylyon-blueprint/04-Modules/ — the nearest is Notifications.md, which scopes the
separate notification engine (notifications, notification_templates,
notification_preferences collections). The client docs in this package are therefore
written forward-looking: the Flutter app is not in the PRD and must be treated as a
design artifact, not a shipped scope. All mobile-only concerns (offline queue, push
delivery, local read-state caching) are marked (forward-looking).
3. Business goals
| Goal | Measure |
|---|---|
| Institutional broadcast in seconds | Draft → publish ≤ 3 taps from composer |
| Audience accuracy | No announcement delivered outside its audience |
| Delivery accountability | Read receipts per announcement, per recipient |
| Low operational noise | Recipients see only announcements targeted at them |
4. User goals
- Org Admin / Principal: publish institution-wide or targeted notices; see who has read them.
- Teacher / Staff: announce to one class/section or a role; follow up with laggards.
- Student / Parent: never miss a school notice in the feed; open + auto-acknowledge.
- Anyone: lightweight 1:1/group chat on threads.
5. Why this exists
Schools still run physical notice boards and ad-hoc WhatsApp groups. Announcements
consolidate this into one tenant-isolated, auditable channel with targeting and read
tracking; threads replace informal group chat. Per docs/IMPLEMENTATION_PLAN.md:174,
announcements are also indexed by global search.
6. Module status matrix
| Capability | Status | Source |
|---|---|---|
| Create announcement (draft) | Implemented | announcements.controller.ts:23-27 |
| List (filter by audience) | Implemented | announcements.controller.ts:29-33 |
| Publish + broadcast | Implemented | announcements.controller.ts:35-39 |
| Mark read | Implemented | announcements.controller.ts:41-45 |
| Read receipts | Implemented | announcements.controller.ts:47-51 |
Detail GET /announcements/:id | Gap | no route |
| Update / delete / archive | Gap | no route |
| Priority / pinned / expiry | (planned) | not in announcement.schema.ts |
| Templates | (planned) | IMPLEMENTATION_PLAN.md:231 |
RBAC communication.* perms | Gap | absent from permissions.constants.ts |
| Event→queue routing | Gap | absent from event-queue-map.ts |
| WebSocket realtime | (planned) | IMPLEMENTATION_PLAN.md:119 |
7. Platform dependencies (backend)
- NestJS 11 strict TS, JWT auth (
JwtAuthGuard,communication.module.ts:24,43) - MongoDB
announcementscollection, tenant-scoped viaBaseRepository(announcement.schema.ts:31,57) - Cross-module reads:
GradeRepository,SectionRepository,StudentRepository,OrganizationMembermodel (announcement.service.ts:6-8,25-26) - Domain events via
EventBus(AnnouncementCreated/AnnouncementPublished,announcement.service.ts:39-51,80-91) — queue fan-out is not yet wired (event-queue-map.tshas no Announcement routes)
8. Out of scope for this package
Notification engine (channels, preferences, templates) — Notifications module;
broadcast templates — (planned); delivery push/email mechanics — handled by the
Notifications module consumers of the emitted events.
02 — User Personas (Communication Module)
- 1. Aisha — Org Admin / Principal (
Author-Power) - 2. Daniel — Teacher (
Author-Local) - 3. Zainab — Student (
Recipient) — PRD native-app excluded,(forward-looking) - 4. Mrs. Okoro — Parent (
Recipient-Light) - 5. Samuel — Staff / Receptionist (
Reader+Chatter) - 6. Non-human actors
- 7. Persona → capability matrix
Who composes, publishes, receives, and measures announcements. Backend-verified against
AudienceType(announcement.schema.ts:7-13) and audience resolution (announcement.service.ts:109-148).
1. Aisha — Org Admin / Principal (Author-Power)
- Goals: publish whole-school notices; hold people accountable via read receipts.
- Behavior: writes 2-5 announcements/week (holidays, events, emergencies); expects
draft→publish in seconds; checks
GET /announcements/:id/readsafter 24 h. - Audience needs: broadcast-all (
AudienceType.ALL), role targeting (AudienceType.ROLE→orgMemberModel.find({ roles: value, status: 'active' }),announcement.service.ts:114-119). - Frustrations: can't see who hasn't read; no expiry; no priority pinning
(
(planned)).
2. Daniel — Teacher (Author-Local)
- Goals: announce to exactly his Grade 10 / Section 10-A; no noise elsewhere.
- Behavior: posts homework reminders, test changes; targets one class at a time.
- Audience needs:
AudienceType.GRADEandAudienceType.SECTION— resolved viagradeRepo.findOne({ name })/sectionRepo.findOne({ name })then students of that group (announcement.service.ts:120-135). - Frustrations: audience value is a name string, not an id — typo = empty
audience (
findOnereturns[],announcement.service.ts:122,129).
3. Zainab — Student (Recipient) — PRD native-app excluded, (forward-looking)
- Goals: never miss a school notice; read fast on mobile.
- Behavior: checks feed morning/evening; taps detail; expects unread badge count to clear.
- Needs: feed of announcements whose
targetUserIds(orAudienceType.ALL) include her; one-tap "mark read" (POST /announcements/:id/read). - Frustrations: the feed endpoint does not filter by current user — it returns
all announcements in the tenant (
announcement.service.ts:56-60); client must pre-filter againsttargetUserIdslocally ((forward-looking)).
4. Mrs. Okoro — Parent (Recipient-Light)
- Goals: see school notices for her child; skim, not study.
- Behavior: opens push/email link straight to detail; rarely composes.
- Needs: short titles, clear body, attachments; explicit audience line ("To: Grade 10").
- Frustrations: no per-child delivery — targeting is by student
userId, and parent delivery is only viaAudienceType.ROLE: 'parent'(announcement.service.ts:114-119).
5. Samuel — Staff / Receptionist (Reader+Chatter)
- Goals: read internal notices; message a colleague 1:1.
- Behavior: participates in threads (
threads.controller.ts); reads announcements aimed at staff role. - Needs: tabs "For me / All"; thread list with unread counts.
6. Non-human actors
- EventBus consumers —
AnnouncementCreated/AnnouncementPublishedpayloads (announcement.service.ts:39-51,80-91;communication-events.ts:9-13); queue routing(planned). - Notifications engine — downstream, out of this module's scope
(
04-Modules/Notifications.md).
7. Persona → capability matrix
| Capability | Aisha | Daniel | Zainab | Mrs. Okoro | Samuel |
|---|---|---|---|---|---|
| Compose announcement | ✓ | ✓ | ✗ | ✗ | ✗ |
| Publish / broadcast | ✓ | ✓ | ✗ | ✗ | ✗ |
| Read receipts view | ✓ | ✓ | ✗ | ✗ | ✗ |
| Feed + mark read | ✓ | ✓ | ✓ | ✓ | ✓ |
| Threads / messages | ✓ | ✓ | ✓ | ✓ | ✓ |
03 — User Journeys (Communication Module)
- 1. Author journey: Draft → Publish → Measure (Aisha, Daniel)
- 2. Recipient journey: Feed → Detail → Read (Zainab)
- 3. Manager journey: Read-receipt audit (Aisha)
- 4. Chatter journey: 1:1/group messaging (Samuel)
- 5. Error / exception paths
End-to-end journeys mapped to backend flow. Backend behavior cited with
file:line.
1. Author journey: Draft → Publish → Measure (Aisha, Daniel)
| Step | Action | Backend call | Backend behavior |
|---|---|---|---|
| 1 | Open composer | — | local form state (draft is client-side only) |
| 2 | Title, body, audience | — | validated per CreateAnnouncementDto |
(create-announcement.dto.ts:29-49) | |||
| 3 | Save draft | POST /announcements | creates doc with published: false and |
createdBy from tenant context (announcement.service.ts:31-37); emits | |||
AnnouncementCreated (:39-51) | |||
| 4 | Publish | POST /announcements/:id/publish | idempotent — returns early if already |
published (announcement.service.ts:68-70); resolves audience → targetUserIds | |||
(:73-75,109-148); sets published: true, publishedAt (:76-78); emits | |||
AnnouncementPublished (:80-91) | |||
| 5 | Monitor | GET /announcements/:id/reads | returns readBy[] receipts |
(announcement.service.ts:104-107) | |||
| 6 | Follow up | manual (no reminder automation) | — |
Pain points: AudienceType.ALL resolves to [] (announcement.service.ts:144-146)
— broadcast-all currently carries no resolved targets; unread-laggard export is a gap;
expiry/priority (planned).
2. Recipient journey: Feed → Detail → Read (Zainab)
| Step | Action | Backend call | Backend behavior |
|---|---|---|---|
| 1 | Open feed | GET /announcements | list, sort: { createdAt: -1 } |
(announcement.service.ts:56-60); optional ?audience= filter | |||
| 2 | Client-side "For me" | — | must match targetUserIds against own id locally; |
ALL-type entries are always "for me" (forward-looking) — backend does not filter | |||
(announcement.service.ts:56-60) | |||
| 3 | Open detail | GET /announcements/:id — gap: no route; reuse list item payload | |
(forward-looking) | — | ||
| 4 | Read + acknowledge | POST /announcements/:id/read | $addToSet receipt |
{userId, readAt} — idempotent (announcement.repository.ts:20-33) | |||
| 5 | Check attachment | — | attachments: string[] stored on doc |
(create-announcement.dto.ts:45-49) |
3. Manager journey: Read-receipt audit (Aisha)
- Open "My announcements" (list) → pick one → receipt breakdown:
total targeted (
targetUserIds.length), read count (readBy.length), unread (targetUserIds − readBy.userId). All derivable fromGET /announcements/:id/readstargetUserIdsfrom list payload.
- Note:
readsreturns receipts but not the target list; client needs both (announcement.service.ts:104-107).
4. Chatter journey: 1:1/group messaging (Samuel)
- Threads list → open thread (
GET /threads,GET /threads/:id) → send (POST /messages); realtime delivery via WebSocket(planned),docs/IMPLEMENTATION_PLAN.md:119. (Messages are secondary surface in this package; see 04_Information_Architecture.md.)
5. Error / exception paths
| Situation | Backend behavior | Client handling |
|---|---|---|
| Publish nonexistent id | NotFoundException announcement.service.ts:64 | error snackbar |
| Mark read nonexistent id | NotFoundException announcement.service.ts:100 | toast, refresh list |
| Audience name typo (grade/section) | resolves [] targets silently | |
(announcement.service.ts:122,129) | composer warns "0 recipients" | |
(planned) — backend does not validate | ||
| Duplicate read tap | $addToSet no-op (announcement.repository.ts:27-31) | idempotent UI |
04 — Information Architecture (Communication Module)
- 1. Route tree
- 2. Content model (from backend,
announcement.schema.ts:31-53) - 3. Audience taxonomy
- 4. State diagram (document lifecycle)
- 5. Module boundaries
Screen tree, navigation, and content model. Route conventions follow 00-shared/05.
1. Route tree
/announcements Announcement feed (recipient)
├── /announcements/:id Detail (sheet on mobile) (forward-looking: no GET :id API)
│ └── attachments external/browser
├── /announcements/compose Composer (author only)
│ └── audience picker nested sheet
├── /announcements/mine Author's own list (drafts + published)
│ └── /announcements/:id/reads Read receipts (author only)
/threads Conversation list
└── /threads/:id Thread detail (messages)
All routes live under the authenticated shell; entry from the dashboard "Communication"
hub and from global search (docs/IMPLEMENTATION_PLAN.md:174 — announcements indexed).
2. Content model (from backend, announcement.schema.ts:31-53)
Announcement
├── title: String (required, trimmed) :33-34
├── body: String (required, trimmed) :36-37
├── audience: { type: enum, value?: string|string[] } :39-40 (enum :7-13)
│ └── all | role | grade | section | custom
├── targetUserIds: ObjectId[] (ref User, default []) :42-43
├── published: Boolean (default false) :45-46 ← draft when false
├── publishedAt?: Date :48-49
├── readBy?: [{ userId, readAt }] :51-52
└── + BaseSchema (tenantId, timestamps, soft-delete)
Index: { tenantId: 1, published: 1, createdAt: -1 } (:57) — the feed's natural
sort/filter shape.
3. Audience taxonomy
AudienceType (announcement.schema.ts:7-13) | value shape | Resolution (announcement.service.ts:109-148) |
|---|---|---|
all | none | [] — broadcast marker, targets empty :144-146 |
role | string (e.g. teacher) | org members roles: value, status: 'active' → userIds :114-119 |
grade | string (grade name) | gradeRepo.findOne({name}) → students of grade :120-125 |
section | string (section name) | sectionRepo.findOne({name}) → students of section :126-135 |
custom | string[] userIds | direct ObjectId map :136-143 |
4. State diagram (document lifecycle)
draft (published:false) ──publish──► published (publishedAt set, targets resolved)
▲ │
│ re-edit (planned) │ read (POST :id/read → $addToSet receipt)
│ ▼
no update route today published + readBy[...]
│
▼
archived/expired (planned — no field in schema)
5. Module boundaries
- Announcements read across modules: academics (grade/section), students,
rbac (
OrganizationMember) —communication.module.ts:38-40,announcement.service.ts:6-8,25-26. - Writes go out via domain events:
AnnouncementCreated,AnnouncementPublished(communication-events.ts:9-13); queue fan-out(planned)—event-queue-map.tshas no Announcement routes today. - Notifications engine owns delivery channels (
studylyon-blueprint/04-Modules/Notifications.md) — out of scope here.
05 — Screen Inventory (Communication Module)
- Legend
- 1. Announcement Feed (
/announcements) — all authenticated users - 2. Announcement Detail (
/announcements/:id— bottom sheet on mobile, page on tablet) - 3. Compose Announcement (
/announcements/compose) — authors - 4. My Announcements (
/announcements/mine) — authors - 5. Read Receipts (
/announcements/:id/reads— sheet) — authors - 6. Threads List (
/threads) — module secondary surface - 7. Thread Detail / Chat (
/threads/:id) - 8. Messaging Compose (new thread / 1:1) — light spec
- Shared components used
- Analytics events (proposed)
- Keyboard, landscape, tablet, desktop
Every screen of the Communication module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics. Format mirrors
design-docs/auth/05_Screen_Inventory.md.
Legend
States = idle / loading / success / empty / error(offline, network) / disabled / permission.
Analytics events follow {module}.{screen}.{action} (proposed; SDK open — 00-shared/10 §8).
Permissions: controller is guarded by JwtAuthGuard only
(announcements.controller.ts:11,18) — no communication.* RBAC perms exist yet
(absent from permissions.constants.ts); role gates below are design intent (planned).
1. Announcement Feed (/announcements) — all authenticated users
| Field | Detail |
|---|---|
| Purpose | Consume school announcements; primary recipient surface |
| Entry | dashboard Communication hub, app open, push tap (forward-looking) |
| Exit | detail sheet; compose (authors); /announcements/mine |
| Source | GET /announcements (+ optional ?audience=) → sort: { createdAt: -1 } (announcement.service.ts:56-60) |
| Tabs | "For me" (client-filtered: targetUserIds contains me, or audience.type === 'all') / "All" — client-side only (forward-looking), backend does not filter by user (announcement.service.ts:56-60) |
| Row widgets | AppCard announcement tile: title, body excerpt, audience chip, status badge (draft/published), unread dot, date |
| Pull-to-refresh | RefreshIndicator re-fetches list |
| Empty | "No announcements" + CTA "Create one" (authors) |
| States | loading skeleton; error offline banner + retry; permission (401 → login) |
| Analytics | comm.announcements.{open,refresh,tab_switch} (proposed) |
| a11y | unread dot exposed via semantics label "Unread"; chips readable by TalkBack |
| Motion | list fade m-base; new item slide-in m-base (motion tokens 00-shared/08) |
2. Announcement Detail (/announcements/:id — bottom sheet on mobile, page on tablet)
| Field | Detail |
|---|---|
| Purpose | Full body, audience line, attachments, mark-as-read action |
| Entry | feed row tap, search result, push deep link (forward-looking) |
| Exit | back; attachments via external browser |
| Source | Gap: no GET /announcements/:id route (announcements.controller.ts has only create/list/publish/read/reads). Use the list payload + receipt action (forward-looking) |
| Actions | POST /announcements/:id/read — idempotent $addToSet (announcement.repository.ts:20-33); auto-fire on open for recipients (design choice, (proposed)) |
| Audience line | "To: Grade 10 · 120 recipients" from audience.type/value + targetUserIds.length |
| Status badge | draft / published + publishedAt local-formatted |
| Empty | not applicable (not-found → snackbar + pop) |
| a11y | body as single readable block; read-state announced via live region |
| Analytics | comm.announcement.detail.{open,mark_read,attachment_open} (proposed) |
3. Compose Announcement (/announcements/compose) — authors
| Field | Detail |
|---|---|
| Purpose | Create draft; audience targeting; publish in one flow |
| Entry | feed FAB, /announcements/mine FAB, empty-state CTA |
| Exit | save draft → success → feed/mine; publish → success → feed |
| Source | POST /announcements (draft, published: false, createdBy from context — announcement.service.ts:31-37) then optional POST /announcements/:id/publish |
| Form | title, body, audience picker (type + value), attachments (planned: upload), priority/expiry (planned — no fields in schema) — see 08_Form_Specifications.md |
| Audience warning | "0 recipients will receive this" when resolution yields [] (role/grade/section typos) (planned) — backend resolves silently (announcement.service.ts:109-148) |
| States | validation inline; draft saved snackbar; publish spinner |
| Analytics | comm.announcement.compose.{save_draft,publish,audience_change} (proposed) |
| Keyboard | title .text, body multiline; toolbar above keyboard |
| a11y | labels linked; audience picker as radio group |
4. My Announcements (/announcements/mine) — authors
| Field | Detail |
|---|---|
| Purpose | Author's own drafts + published list |
| Source | same GET /announcements; client filters createdBy == me — backend has no "mine" query (forward-looking) |
| Row | announcement tile + status badge (draft / published) + reads count |
| Empty | "No announcements yet" + FAB compose |
| Analytics | comm.announcements.mine.{open,status_filter} (proposed) |
5. Read Receipts (/announcements/:id/reads — sheet) — authors
| Field | Detail |
|---|---|
| Purpose | Who read / who hasn't |
| Source | GET /announcements/:id/reads → readBy[] (announcement.service.ts:104-107); read count + targetUserIds.length from list payload |
| Rows | AppListTile: name (user lookup), readAt; segmented "Read / Unread" (unread = targetUserIds − readBy) |
| States | loading; empty (nobody read yet) |
| Analytics | comm.announcement.reads.{open,segmented_switch} (proposed) |
| Note | export / laggard reminders (planned) |
6. Threads List (/threads) — module secondary surface
| Field | Detail |
|---|---|
| Source | GET /threads (threads.controller.ts) |
| Row | AppListTile conversation: partner avatar, last message, unread badge |
| Empty | "No conversations" + FAB new thread |
| Analytics | comm.threads.{open,search} (proposed) |
7. Thread Detail / Chat (/threads/:id)
| Field | Detail |
|---|---|
| Source | GET /threads/:id, send POST /messages, mark read PATCH /threads/:id/read (IMPLEMENTATION_PLAN.md:107-112) |
| Realtime | WebSocket via existing WsModule (planned) — IMPLEMENTATION_PLAN.md:119 |
| States | offline → outbox queue (forward-looking) |
| Analytics | comm.thread.{open,send,read} (proposed) |
8. Messaging Compose (new thread / 1:1) — light spec
Recipient picker (user search), body field; creates via POST /threads + POST /messages.
Full spec deferred — threads are secondary in this package.
Shared components used
AppCard, AppListTile, AppButton, AppTextField, AppSnackbar, AppChips,
AppBadge, AppBottomSheet, AppDialog, AppMenu, AppSkeleton, AppEmptyState,
AppOfflineBanner, AppFAB, AppAvatar, AppSegmentedControl. Module-specific:
AnnouncementCard, AudienceChip, AnnouncementStatusBadge, ReadReceiptRow,
AudiencePicker — defined in 07_Component_Library.md.
Analytics events (proposed)
comm.announcements.*, comm.announcement.{detail,compose,reads}.*,
comm.threads.*, comm.thread.* — all proposed (SDK open, 00-shared/10).
Keyboard, landscape, tablet, desktop
- Feed/detail: portrait-first; tablet → two-column list/detail; desktop hover highlights.
- Composer: full keyboard-avoidance +
resizeToAvoidBottomInset; tablet centers card ≤ 640 dp. - Receipts: tablet shows segmented read/unread side-by-side.
06 — Screen Specifications (Communication Module)
- 1. Announcement Feed (
/announcements) - 2. Announcement Detail (
/announcements/:id) - 3. Compose Announcement (
/announcements/compose) - 4. Read Receipts (
/announcements/:id/reads) - 5. Threads List (
/threads) — secondary - 6. Thread Detail (
/threads/:id) — secondary - 7. Cross-screen invariants
- 8. Open questions
Detailed, build-ready specs for the announcement surface. Biggest file in this package. Data contracts are verbatim from
announcement.schema.ts,create-announcement.dto.ts,announcement.service.ts,announcements.controller.ts; gaps are flagged(planned)/(forward-looking)and must not be invented as shipped behavior.
1. Announcement Feed (/announcements)
1.1 Purpose & entry
Recipient-first consumption surface. Entry: dashboard Communication hub, cold start,
push deep link (forward-looking).
1.2 Data contract
GET /announcements → AnnouncementDocument[] (no pagination in backend;
announcement.service.ts:56-60 returns full array sorted createdAt: -1).
Item shape (schema announcement.schema.ts:31-53 + BaseSchema):
{
"_id": "…", "tenantId": "…", "title": "Diwali holiday",
"body": "School closed Fri.",
"audience": { "type": "grade", "value": "Grade 10" },
"targetUserIds": ["…", "…"],
"published": true, "publishedAt": "2026-…",
"readBy": [{ "userId": "…", "readAt": "2026-…" }],
"attachments": ["https://…/notice.pdf"],
"createdBy": "…", "createdAt": "…", "updatedAt": "…"
}
1.3 Layout (mobile)
┌────────────────────────────┐
│ AppBar "Announcements" [FAB+]│
│ TabBar: For me | All │
│ ┌──────────────────────────┐│
│ │ AnnouncementCard ││
│ │ [•unread] Diwali holiday ││
│ │ School closed Fri.… ││
│ │ [grade] [Published] 2d ││
│ └──────────────────────────┘│
│ … (list) │
└────────────────────────────┘
1.4 Behavior
- "For me" tab (client-side): include item if
audience.type === 'all'OR my userId ∈targetUserIds. Backend never filters per user (announcement.service.ts:56-60) → client filter(forward-looking). - Unread indicator:
readBylacks my userId → unread dot (announcement.schema.ts:51-52). - Audience chip: derived from
audience.type+value(e.g.role: teacher,section: 10-A) (announcement.schema.ts:7-21). - Status badge: draft (
published: false) vs published (:45-49); draft rows only meaningful on "All"/"Mine". - Pull-to-refresh; infinite scroll not supported (no pagination API — gap,
(planned)when tenant grows). - Tablet: list + detail two-pane (master-detail per
00-shared/05).
1.5 States
| State | Render |
|---|---|
| loading | AppSkeleton rows ×6 |
| success | list |
| empty | AppEmptyState "No announcements yet" + compose CTA (authors) |
| error offline | AppOfflineBanner + cached list (forward-looking); retry |
| auth expired | 401 → login redirect |
1.6 Events
comm.announcements.open|refresh|tab_switch (proposed).
2. Announcement Detail (/announcements/:id)
2.1 Data contract — GAP
No GET /announcements/:id route exists (announcements.controller.ts:19-52). The client
must carry the item object from the feed/list, or the row must be a summary-only sheet.
A dedicated detail fetch is (planned).
2.2 Layout (sheet on mobile / pane on tablet)
┌────────────────────────────┐
│ [badge: published] [chip] │
│ Title (headline) │
│ To: Grade 10 · 120 people │ ← audience.value + targetUserIds.length
│ Published 3 Aug 2026 │
│ ─────────────────────────── │
│ Body paragraph text │
│ ─────────────────────────── │
│ [attachment] notice.pdf ↗ │
│ ─────────────────────────── │
│ [ Mark as read ] (filled) │
└────────────────────────────┘
2.3 Behavior
- Mark read:
POST /announcements/:id/read—$addToSetreceipt, idempotent (announcement.repository.ts:20-33); NotFound → snackbar (announcement.service.ts:100). - Auto-mark on open is a product decision
(proposed); explicit button also shown for drafts-only confusion. - Audience line:
audience.valuefor role/grade/section/custom; "Everyone" forall; count =targetUserIds.length(0 forall—announcement.service.ts:144-146; show "Everyone" without a count in that case to avoid "0 people" confusion). - Drafts: authors see draft detail (no read button; "Publish" CTA instead).
2.4 States
loading (if fetched (planned)), success, not-found (pop + snackbar), offline.
2.5 Events
comm.announcement.detail.{open,mark_read,attachment_open} (proposed).
3. Compose Announcement (/announcements/compose)
3.1 Layout (scrollable form)
┌────────────────────────────┐
│ AppBar "New announcement" │
│ [Save draft] [Publish] │ ← Publish = save + publish
│ Title* │ AppTextField, min 1 char
│ Body* │ multiline, min 1 char
│ Audience* │ [type picker] [value field/selector]
│ type: all | role | grade | section | custom
│ value: string or string[] (see 3.3)
│ ⚠ 0 recipients warning (planned)
│ Attachments (planned) │ chips + picker
│ Priority / Expiry (planned) │ toggle + date picker — NO schema fields yet
└────────────────────────────┘
3.2 Data contract (verbatim create-announcement.dto.ts:29-49)
{
"title": "string (min 1)",
"body": "string (min 1)",
"audience": {
"type": "all|role|grade|section|custom",
"value": "string | string[]" // required per type; absent for "all"
},
"attachments": ["string"] // optional
}
3.3 Audience picker rules
Type (announcement.schema.ts:7-13) | Value input | Resolves to |
|---|---|---|
all | none (value omitted) | [] — broadcast marker only (announcement.service.ts:144-146) |
role | enum string — e.g. teacher | active org members with role |
(announcement.service.ts:114-119) | ||
grade | grade name string | students of grade (:120-125) |
section | section name string | students of section (:126-135) |
custom | userId chips | the listed users (:136-143) |
- Grade/section inputs are names, not ids (
findOne({ name: audience.value }),announcement.service.ts:121,128) — present a picker fed by the academics module, not free text, to avoid silent empty audiences. - Zero-recipient warning
(planned): after publish attempt backend resolves silently to[](announcement.service.ts:122,129) — client should preview count(planned).
3.4 Save vs Publish
- Save draft:
POST /announcements→published: false+createdByfrom token context (announcement.service.ts:31-37); emitsAnnouncementCreated(:39-51). - Publish: same POST then
POST /announcements/:id/publish→ resolves audience totargetUserIds, setspublished: true,publishedAt(:68-93); emitsAnnouncementPublished(:80-91). Idempotent re-publish returns early (:68-70). - Editing an existing draft not supported (no update route) — gap; re-create.
(planned).
3.5 States & validation
| Check | Rule | Source |
|---|---|---|
| title | required, min 1 char, trimmed | create-announcement.dto.ts:30-33; announcement.schema.ts:33-34 |
| body | required, min 1 char, trimmed | create-announcement.dto.ts:35-38; announcement.schema.ts:36-37 |
| audience.type | required enum | create-announcement.dto.ts:14-16; announcement.schema.ts:16-17 |
| audience.value | optional string | string[], strings only | create-announcement.dto.ts:24-26 |
| attachments | optional string[] | create-announcement.dto.ts:45-49 |
Publish with empty audience.value for role/grade/section → block + inline error
(planned); today backend accepts and resolves [] (announcement.service.ts:120-143).
4. Read Receipts (/announcements/:id/reads)
4.1 Data contract
GET /announcements/:id/reads → AnnouncementDocument['readBy'] — array of
{ userId, readAt } (announcement.service.ts:104-107, announcement.schema.ts:23-29).
Does not include targetUserIds — client needs the list payload for the unread
complement.
4.2 Layout
┌────────────────────────────┐
│ "Diwali holiday" │
│ 72 of 120 read · 48 unread │ ← readBy.length / targetUserIds.length
│ [Read] [Unread] (segmented)│
│ • Aisha Idris 3 Aug 9:04 │
│ • Samuel Eze 3 Aug 9:12 │
└────────────────────────────┘
4.3 Behavior
- Unread set =
targetUserIds − readBy.userId(client-side; user display names from user lookup(forward-looking)— no join in API). - Refresh on pull; auto-refresh 30 s while open
(proposed). - Export / remind laggards
(planned). - For
all-type announcementstargetUserIdsis[]→ counts meaningless; hide numbers, show receipts list only.
5. Threads List (/threads) — secondary
GET /threads (threads.controller.ts); rows: partner avatar, last message preview,
unread badge. Empty state + FAB. Tablet two-pane.
6. Thread Detail (/threads/:id) — secondary
GET /threads/:id + POST /messages + PATCH /threads/:id/read
(docs/IMPLEMENTATION_PLAN.md:107-112). Bubbles, input bar, optimistic send, offline
outbox (forward-looking). WebSocket realtime (planned) (IMPLEMENTATION_PLAN.md:119).
Full spec deferred to a dedicated messaging package.
7. Cross-screen invariants
published: false⇒ never in recipient "For me"; authors see "Draft" badge.- Unread state =
myUserId ∉ readBy[].userId— always derived, never cached server-side. AudienceType.ALL⇒targetUserIds.length === 0— treat as "everyone", never as "0".- Every write that touches a doc emits an event (
AnnouncementCreated/AnnouncementPublished,announcement.service.ts:39-51,80-91) — client may subscribe via WS(planned)for live feed updates. - All reads tenant-scoped by
BaseRepository(announcement.repository.ts:12-18) — cross-tenant leakage impossible at API level; client must not assume the same for display names.
8. Open questions
- OQ-1: Should
GET /announcementspaginate before feed ships? (backend today returns unbounded array —announcement.service.ts:56-60) - OQ-2: Should opening detail auto-mark read, or require explicit tap?
- OQ-3: Are grade/section pickers fed by academics API (
GET /academics/…) — needs module contract check. - OQ-4: When
communication.*RBAC perms arrive, which roles may compose/publish? (blueprint:notification.*analog —studylyon-blueprint/04-Modules/Notifications.md:65-71)
07 — Component Library (Communication Module)
- 1. AnnouncementCard
- 2. AudienceChip
- 3. AnnouncementStatusBadge
- 4. ReadReceiptBar
- 5. ReadReceiptRow
- 6. AudiencePicker (composer)
- 7. ComposerField (reused trio)
- 8. ThreadTile / MessageBubble (secondary surface)
- Composability map (screen → component)
Module-specific components. Reuse 00-shared/03 (App*) for everything generic; only announcement-specific composites live here. All components are stateless widgets driven by Cubit state (see 13_State_Management.md).
1. AnnouncementCard
| Property | Type | Notes |
|---|---|---|
| announcement | Announcement model | from GET /announcements payload (announcement.schema.ts:31-53) |
| isUnread | bool | derived: myUserId ∉ readBy[].userId |
| onTap | VoidCallback | → detail |
| trailing | widget? | optional AppMenu (authors: view receipts) |
Render: AppCard + AppListTile: unread dot (leading, 8 dp amber dot — semantics
"Unread"), title titleMedium, body excerpt bodySmall maxLines 2, row of
AudienceChip + AnnouncementStatusBadge, createdAt relative time.
Draft rows show AnnouncementStatusBadge(draft) and no unread dot (recipients never see
drafts — draft filter is published: false, announcement.schema.ts:45-46).
States: idle / loading (AppSkeleton variant) / error.
2. AudienceChip
| Property | Type | Notes |
|---|---|---|
| audience | AnnouncementAudience | { type, value } (announcement.schema.ts:15-21) |
Label mapping (verbatim enum, announcement.schema.ts:7-13):
| type | label |
|---|---|
all | "Everyone" |
role | value (e.g. "Teacher") |
grade | value (e.g. "Grade 10") |
section | value (e.g. "10-A") |
custom | "Selected people" (+count if known) |
AppChip with module palette (see 11_Design_System_Mapping.md).
3. AnnouncementStatusBadge
| Property | Type | Notes |
|---|---|---|
| published | bool | announcement.schema.ts:45-46 |
true→ "Published" +publishedAton detailfalse→ "Draft" (publishedAtabsent —:48-49)
AppBadge; published = success color, draft = neutral.
4. ReadReceiptBar
| Property | Type | Notes |
|---|---|---|
| readCount | int | readBy.length (announcement.service.ts:104-107) |
| targetCount | int | targetUserIds.length; 0 ⇒ render "Everyone", hide ratio (announcement.service.ts:144-146) |
Render: progress bar (readCount/targetCount) + "72 of 120 read". Used on
"AnnouncementCard(authors)" and receipts sheet.
5. ReadReceiptRow
| Property | Type | Notes |
|---|---|---|
| userId | string | receipt.userId (announcement.schema.ts:24-25) |
| readAt | DateTime | :27-28 |
| displayName | string | user lookup (forward-looking) — API returns ids only |
AppListTile: avatar, name, "Read 3 Aug 9:04". Unread variant shows "Not read yet".
6. AudiencePicker (composer)
| Property | Type | Notes |
|---|---|---|
| value | AnnouncementAudience | { type, value? } |
| onChanged | callback | |
| options | enum list | all|role|grade|section|custom (announcement.schema.ts:7-13) |
Render: 5 AppChoiceChips + conditional value input:
- role → dropdown of org roles (RBAC constants
(planned)— no perms exist today) - grade / section → picker fed by academics module data (values are names,
announcement.service.ts:121,128) - custom → user search + chips
- all → none; label "Everyone in your school"
7. ComposerField (reused trio)
AppTextField variants bound to DTO: title (single-line), body (multiline),
attachments chip list (create-announcement.dto.ts:29-49). Validation mirrors
class-validator rules (see 08_Form_Specifications.md §5).
8. ThreadTile / MessageBubble (secondary surface)
AppListTile (thread row) + outgoing/incoming bubble with sentAt, delivery tick
(WebSocket (planned) — IMPLEMENTATION_PLAN.md:119). Deferred detail to messaging
package.
Composability map (screen → component)
| Screen (05) | Components |
|---|---|
| Feed | AnnouncementCard, AudienceChip, AnnouncementStatusBadge, App* |
| Detail | AnnouncementStatusBadge, AudienceChip, AppSheet/Page |
| Compose | AudiencePicker, ComposerField, App* |
| Mine | AnnouncementCard, AnnouncementStatusBadge |
| Receipts | ReadReceiptBar, ReadReceiptRow |
| Threads | ThreadTile, MessageBubble |
08 — Form Specifications (Communication Module)
- 1. Form map
- 2. Audience picker — value semantics per type
- 3. Submit model
- 4. Draft semantics
- 5. Client validation (mirrors class-validator)
- 6. Sub-states
- 7. a11y
- 8. Priority & expiry —
(planned)design intent
The Compose Announcement form is the only form in this module (thread compose is a lightweight input bar, deferred). Field rules are verbatim from
create-announcement.dto.ts; anything beyond it is(planned)and does not exist on the backend today.
1. Form map
| Field | Required | Control | Source rule |
|---|---|---|---|
| Title | ✓ | AppTextField single-line | IsString, MinLength(1) (create-announcement.dto.ts:30-33); trimmed on save (announcement.schema.ts:33-34) |
| Body | ✓ | AppTextField multiline | IsString, MinLength(1) (create-announcement.dto.ts:35-38); trimmed (announcement.schema.ts:36-37) |
| Audience type | ✓ | AudiencePicker choice chips | IsEnum(AudienceType) (create-announcement.dto.ts:14-16); enum all|role|grade|section|custom (announcement.schema.ts:7-13) |
| Audience value | conditional | picker / chips / none | IsOptional, IsString({ each: true }), string | string[] (create-announcement.dto.ts:24-26) |
| Attachments | ✗ | chip list + picker | IsOptional, IsArray, IsString({each}) (create-announcement.dto.ts:45-49) |
| Priority | — | not a backend field | (planned) — absent from announcement.schema.ts |
| Expiry | — | not a backend field | (planned) — absent from announcement.schema.ts |
2. Audience picker — value semantics per type
| Type | Value UI | Payload value | Resolution note |
|---|---|---|---|
all | none (hint "Everyone in your school") | omitted | resolves [] (announcement.service.ts:144-146) — broadcast marker |
role | dropdown of roles (RBAC constants) | "teacher" | org members roles: value, status: 'active' → userIds (announcement.service.ts:114-119) |
grade | picker fed by academics (grade names) | "Grade 10" | gradeRepo.findOne({name}) → students (:120-125) — name, not id |
section | picker fed by academics (section names) | "10-A" | sectionRepo.findOne({name}) → students (:126-135) — name, not id |
custom | user search + chips | ["u1","u2"] | direct ObjectId map (:136-143) |
Typo/unknown grade or section name ⇒ backend silently resolves
[](announcement.service.ts:122,129). Client should validate against live pickers and preview the recipient count(planned).
3. Submit model
| Action | Requests | Result |
|---|---|---|
| Save draft | POST /announcements | published: false, createdBy from token (announcement.service.ts:31-37); emits AnnouncementCreated (:39-51) |
| Publish | POST /announcements → POST /announcements/:id/publish | targetUserIds resolved, published: true, publishedAt (:68-93); emits AnnouncementPublished (:80-91) |
Two-step sequencing: publish button performs save-then-publish; if the save fails, nothing publishes; if publish fails (404/network), draft is kept and error surfaced with "Retry publish".
4. Draft semantics
- Draft = document with
published: false(announcement.schema.ts:45-46). - Drafts are never returned specially —
GET /announcementsincludes them in the full list (announcement.service.ts:56-60); client shows them only on "Mine"/author views. - No update route — an existing draft cannot be edited, only recreated.
(planned). AnnouncementCreatedfires on draft save, so downstream channels may receive draft-level events (announcement.service.ts:39-51) — relevant if the client shows "created" toast.
5. Client validation (mirrors class-validator)
| Field | Client rule | Failure UX |
|---|---|---|
| Title | non-empty after trim, ≤ 120 chars (product limit (proposed)) | inline error, focus |
| Body | non-empty after trim | inline error, focus |
| Audience type | one of 5 enum values | radio/chips always select one |
| Audience value | required unless all; strings only | inline error "Choose who sees this" |
| Attachments | URL strings; size/type pre-check client-side only (proposed) | chip-level error |
6. Sub-states
- idle → dirty → saving draft (button spinner, fields disabled) → saved (snackbar "Draft saved", stay on form) → publish (loading, full-screen spinner on second request) → success (pop to feed/mine).
- Network failure: banner + retry; no offline persistence — form state kept in
memory
(forward-looking: local draft store). - Double-tap protection on Publish (idempotent server-side, but avoid duplicate POSTs).
7. a11y
Labels linked to fields; audience chips grouped as radio group (Semantics container);
"0 recipients" warning as live region (planned); keyboard: title .text, body
multiline with toolbar row (attachment, priority (planned)).
8. Priority & expiry — (planned) design intent
When backend adds priority (e.g. normal|high + pinned) and expiry (auto-hide date)
fields — flagged in IMPLEMENTATION_PLAN.md roadmap (docs/IMPLEMENTATION_PLAN.md:231
templates/preferences cluster; none for announcements specifically) — the form gains:
- priority segmented control (normal/high) — high rows pinned on feed
- expiry date picker (
showDatePicker, min = today+1) — expired items filtered client-side and flagged "Expired" Until then these controls MUST NOT render (no schema fields,announcement.schema.ts:31-53).
09 — User Behaviour (Communication Module)
- 1. Consumption behavior
- 2. Composer behavior
- 3. Read-receipt behavior
- 4. Messaging behavior (secondary)
- 5. Failure modes & tolerance
- 6. Trust & privacy
Observed/targeted behavior patterns per persona; informs layout, frequency of actions, and failure modes. Backend constraints cited where they shape behavior.
1. Consumption behavior
| Persona | Cadence | Session type | Core action |
|---|---|---|---|
| Student (Zainab) | 2×/day (morning/evening) | 20-40 s skim | open feed → open unread → auto/explicit read |
| Parent (Mrs. Okoro) | 1×/day + event-triggered | 15 s | deep link → detail → read |
| Teacher (Daniel) | 3-5×/day around classes | 30-60 s | feed + compose class notices |
| Admin (Aisha) | as events occur | 1-2 min | compose, publish, check receipts next day |
Behavioral consequence: the feed must render usable in one hand, show unread at a glance (unread dot 07 §1), and open detail in ≤ 1 tap. Read action should feel free — no confirmation dialogs.
2. Composer behavior
- Authors compose from templates in their head — no template backend
(
(planned),IMPLEMENTATION_PLAN.md:231); provide no template UI today. - Audience choice is the highest-friction step: authors think "my class" or "all
teachers", not "grade: Grade 10, name lookup". The
AudiencePickermust therefore offer the five types in plain words with a recipient-count preview(planned)— backend resolves counts only at publish (announcement.service.ts:68-93). - Draft habit: authors save-then-publish from mobile; publish-from-feed is a
convenience
(planned)(no such route today — publish requires:idafter create).
3. Read-receipt behavior
- Authors check receipts ~24 h after publish; expect a simple read/unread split.
- The unread split is derivable but requires both payloads:
readBy[]fromGET /announcements/:id/readsandtargetUserIdsfrom the list (announcement.service.ts:104-107+:42-43). - Known trap:
AudienceType.ALLhastargetUserIds = [](announcement.service.ts:144-146) — receipts screen must degrade to "list of who read" without counts to avoid misleading "0 of 0 read". - Laggard follow-up (export/remind)
(planned).
4. Messaging behavior (secondary)
- 1:1 staff chat, quick question cadence; unread thread badge drives return visits.
- Realtime expectation — WebSocket
(planned),IMPLEMENTATION_PLAN.md:119; until then poll(forward-looking)is a fallback.
5. Failure modes & tolerance
| Failure | User reaction | Mitigation in design |
|---|---|---|
| Feed empty | assumes nothing was announced | empty-state CTA for authors; no skeleton ghosting |
| Audience silently empty (typo name) | believes everyone saw it | recipient preview (planned) + warning (06 §3.3) |
| Read tap errors | retries once, then ignores | idempotent $addToSet (announcement.repository.ts:20-33) makes retries safe; silent fire-and-forget + toast |
| Offline feed | expects cached last state | offline banner + last-fetch cache (forward-looking) |
| Duplicate announcements (no edit route) | deletes? can't — no delete route | tolerate duplicates in feed; dedupe by _id client-side |
6. Trust & privacy
- Users believe announcements are private to the tenant — true at API level
(
BaseRepositorytenant scope,announcement.repository.ts:12-18). - Users do not expect read receipts to show who read; admins do. Gate receipts UI
by authorship
(planned)— no RBAC exists yet (permissions.constants.tshas nocommunication.*), so the client hides it for non-authors bycreatedBy == me(announcement.schema.tsBaseSchemacreatedBy;announcement.service.ts:36). - Custom-audience picks expose user ids in payloads (
create-announcement.dto.ts:18-26); never render raw ids, always display names (user lookup(forward-looking)).
10 — Interaction Specification (Communication Module)
- 1. Navigation
- 2. Feed interactions
- 3. Detail interactions
- 4. Compose interactions
- 5. Receipts sheet interactions
- 6. Threads (secondary)
- 7. Motion & feedback map
- 8. Offline & error policy
- 9. A11y essentials
Precise interactions: navigation, gestures, feedback, motion. Tokens/motion from 00-shared/08 (m-fast/m-base), a11y baseline from 00-shared/09, API conventions from 00-shared/07.
1. Navigation
| From | Gesture/CTA | To | Backend relevance |
|---|---|---|---|
| Feed row | tap | Detail (sheet mobile / pane tablet) | no fetch — carry item (06 §2.1 gap) |
| Feed FAB | tap | Compose | authors only (planned) |
| Feed tab | tap For me | All | same screen, refilter | client-side filter (announcement.service.ts:56-60) |
| AnnouncementCard menu | tap | Read receipts (authors) | GET /announcements/:id/reads |
| Compose Publish | tap (loading) | success pop → feed | POST create + POST publish (08 §3) |
| Thread tile | tap | Thread detail | GET /threads/:id |
Back semantics: sheets dismiss; pages pop; deep link to detail needs GET :id
(planned).
2. Feed interactions
- Pull-to-refresh:
RefreshIndicator, 500 ms min spinner; refetch list, preserve scroll position. - Unread dot: 8 dp amber dot, semantics "Unread"; removed optimistically when the user reads the item (before server confirms — rollback on error).
- Draft rows (author): amber-free; "Draft" badge; tap → detail with "Publish" CTA.
- Long-press row:
AppMenu(authors): "View receipts". (No delete — no API.) - Tablet: row tap updates right pane; active row highlighted;
Backrestores list focus.
3. Detail interactions
- Mark as read: filled button; on tap → optimistic checkmark +
POST /announcements/:id/read(announcement.repository.ts:20-33); success → snackbar (suppressed if silent preference(proposed)); 404 → snackbar + pop (announcement.service.ts:100). - Auto-read on open:
(proposed)toggle in settings; default off until product decision (06 §8 OQ-2). - Attachments: tap → external browser (list is URL strings,
create-announcement.dto.ts:45-49); no in-app viewer. - Audience line: pure metadata display; no drill-down (no audience API).
4. Compose interactions
- Field focus flow: title → body → audience;
TextInputAction.nextchain; body multiline with toolbar. - Audience change: selecting a type re-renders value control; picking
allclears value; custom picker is search-as-you-type over users (user search API needed —(planned)). - Recipient preview:
(planned)— count chip below audience ("120 people will see this"); computed client-side only when custom (targetUserIds known); otherwise(planned)server estimate. - Dirty-guard: leaving composer with edits → confirm dialog "Discard draft?".
- Publish flow: Publish = save + publish sequentially (08 §3); button becomes spinner; double-tap disabled; failure keeps draft + error snackbar + retry.
- Draft save: implicit on publish failure; explicit "Save draft" secondary button.
5. Receipts sheet interactions
- Segmented Read/Unread (
AppSegmentedControl): instant refilter, no refetch. - Pull-to-refresh + auto-refresh every 30 s while open
(proposed). - Rows: plain tiles; tap user → profile
(forward-looking). - ALL-audience degradation: counts hidden, list only (09 §3).
6. Threads (secondary)
- Send bar:
TextField+ send button; optimistic bubble; error → red tick + retry. - Unread badge on thread tile;
PATCH /threads/:id/readon open (IMPLEMENTATION_PLAN.md:112). - Realtime via WS
(planned)(IMPLEMENTATION_PLAN.md:119).
7. Motion & feedback map
| Interaction | Motion | Feedback |
|---|---|---|
| Feed load | list fade m-base | skeletons |
| New item appears | slide-in m-base | — |
| Detail open | sheet slide-up m-base / page push m-base | — |
| Mark read | instant checkmark (no delay) | snackbar on error only |
| Publish | button spinner m-fast; success pop m-base | snackbar "Published" |
| Offline | — | AppOfflineBanner + native retry |
8. Offline & error policy
| State | Policy |
|---|---|
| offline, feed | serve last-fetched cache + banner (forward-looking) |
| offline, mark read | queue receipt POST; flush on reconnect (forward-looking) — safe: $addToSet idempotent (announcement.repository.ts:27-31) |
| offline, compose | block publish; keep form state in memory; warn |
| 401 | global → login redirect (JWT guard, announcements.controller.ts:11) |
| 404 mark read / reads | snackbar + pop to list |
| 500 | generic error state + retry |
9. A11y essentials
- Unread dot: semantics "Unread" (never color-only).
- Audience chips:
Semantics(label: "Audience: Grade 10"). - Receipts counts announced on change (live region).
- Compose errors: inline + focus first invalid + live region (00-shared/09).
11 — Design System Mapping (Communication Module)
- 1. Semantic → token map
- 2. Component → token mapping (key ones)
- 3. Status → color rules
- 4. Iconography
- 5. Density & layout
- 6. Dark mode / contrast
Maps module semantics onto shared tokens (00-shared/02 design tokens, 00-shared/04 system mapping). No new tokens without a shared-doc change.
1. Semantic → token map
| Semantic | Token | Component use |
|---|---|---|
| Published | success/primary | AnnouncementStatusBadge |
| Draft | neutral/grey | AnnouncementStatusBadge |
| Unread | accent amber dot | AnnouncementCard leading dot |
| Read | default text/icon | checkmark on detail CTA |
Audience role | info chip | AudienceChip |
Audience grade/section | info chip variant | AudienceChip |
Audience custom | secondary chip | AudienceChip |
Audience all | primary "Everyone" chip | AudienceChip |
Priority high (planned) | error/alert accent | future PriorityBadge — no schema field yet (announcement.schema.ts:31-53) |
Expired (planned) | muted + strikethrough title | future filter state |
2. Component → token mapping (key ones)
| Component (07) | Tokens |
|---|---|
| AnnouncementCard | surface card, outline border, radius-m, space-3/4, shadow-1 |
| AudienceChip | chip tokens + per-type fill (map above) |
| AnnouncementStatusBadge | AppBadge token sets (success/neutral) |
| ReadReceiptBar | progress primary, track surface-variant |
| Compose form | standard AppTextField tokens; error = error color |
Typography: title titleMedium, body bodyMedium/bodySmall (excerpt), audience chip
labelSmall, timestamps labelSmall muted. All from shared type ramp — no module-local
font sizes.
3. Status → color rules
- Color never carries meaning alone — always paired with text/label (00-shared/09): "Draft", "Published", "Unread" semantics.
- Unread amber is reserved for "has unread content" only (feed dot, thread badge).
- Error red = validation and failures only, not for "draft" or "expired".
- High-priority accents
(planned)must contrast ≥ 4.5:1 and pass 00-shared/09 checks.
4. Iconography
| Icon | Meaning |
|---|---|
campaign / megaphone | announcements hub, compose FAB |
mark_email_read / check_circle | mark-read CTA, read state |
visibility / bar_chart | read receipts (author) |
attach_file | attachments |
draft (document) | draft badge |
schedule | expiry (planned) |
Icons from the shared icon set (00-shared/02); no custom glyphs.
5. Density & layout
- Feed rows: comfortable density (
space-4vertical); receipts rows: compact (space-2) — receipts are scanning surfaces. - Composer max width 640 dp on tablet/desktop (forms are hard to fill on wide panes — 00-shared/04).
- Sheets: standard bottom-sheet radius/height rules; receipts sheet can be full-height (scroll-heavy).
6. Dark mode / contrast
All tokens are theme-aware (light/dark from shared tokens). Unread amber, success green, and muted grey all have dark-variant tokens — verify on both themes in QA (14_QA_Checklist.md §9).
12 — API Mapping (Communication Module)
- 1. Announcements API (implemented —
announcements.controller.ts) - 2. Request/response contracts
- 3. Domain events (outbound —
EventBus) - 4. Threads & messages API (secondary surface)
- 5. RBAC — GAP
- 6. Gap list (affects client)
Exact endpoints from source. Announcement API is verbatim (
announcements.controller.ts); threads/messages listed perdocs/IMPLEMENTATION_PLAN.md:104-119. Nocommunication.*permissions exist inpermissions.constants.ts— flagged as gap. Response envelope per 00-shared/07.
1. Announcements API (implemented — announcements.controller.ts)
| # | Method | Path | Summary (from @ApiOperation) | Source |
|---|---|---|---|---|
| 1 | POST | /api/v1/announcements | Create an announcement (draft) | announcements.controller.ts:23-27 |
| 2 | GET | /api/v1/announcements | List announcements (filter by audience type) | :29-33 |
| 3 | POST | /api/v1/announcements/:id/publish | Publish and broadcast an announcement | :35-39 |
| 4 | POST | /api/v1/announcements/:id/read | Mark announcement as read | :41-45 |
| 5 | GET | /api/v1/announcements/:id/reads | Read receipts for an announcement | :47-51 |
Controller-wide: @ApiTags('communication'), @ApiBearerAuth(),
@UseGuards(JwtAuthGuard) (:16-19). No pagination, no query params beyond
?audience= (:31).
2. Request/response contracts
POST /announcements — body (create-announcement.dto.ts:29-49)
{
"title": "string ≥1",
"body": "string ≥1",
"audience": { "type": "all|role|grade|section|custom", "value": "string|string[]?" },
"attachments": ["string"] // optional
}
Response: AnnouncementDocument (announcement.schema.ts:31-53) with
published: false, createdBy from token (announcement.service.ts:31-37).
GET /announcements — query
?audience=<AudienceType> optional → filter['audience.type'] = audience
(announcement.service.ts:56-60). Returns array sorted createdAt: -1, tenant-scoped.
POST /announcements/:id/publish
Idempotent (:68-70); resolves audience → targetUserIds (:73-75,109-148);
sets published: true, publishedAt (:76-78). 404 if missing (:62-66).
POST /announcements/:id/read
$addToSet receipt {userId, readAt} — idempotent (announcement.repository.ts:20-33);
404 if missing (announcement.service.ts:100).
GET /announcements/:id/reads
→ readBy[] (announcement.service.ts:104-107) — {userId, readAt}[]
(announcement.schema.ts:23-29). Does not include targetUserIds.
3. Domain events (outbound — EventBus)
| Event | Emitted | Payload | Source |
|---|---|---|---|
AnnouncementCreated | create (draft!) | {announcementId, title, body, audienceType} | announcement.service.ts:39-51 |
AnnouncementPublished | publish | {announcementId, title, audienceType} | :80-91 |
Interface AnnouncementPublishedEvent (communication-events.ts:9-13).
Queue routing — GAP: event-queue-map.ts has no AnnouncementCreated /
AnnouncementPublished entries (it maps UserRegistered → emails,
HomeworkCreated → in-app, etc., event-queue-map.ts:6-43). Nothing consumes these
events into BullMQ today. Intended mapping (planned), following existing patterns:
AnnouncementPublished → in-app (+ emails for broadcast-all) — fan-out design pending
Notifications module (studylyon-blueprint/04-Modules/Notifications.md:34-44).
4. Threads & messages API (secondary surface)
| Method | Path | Source |
|---|---|---|
| POST | /api/v1/messages | send (1:1 or group) — IMPLEMENTATION_PLAN.md:107 |
| GET | /api/v1/messages | list my messages — :108 |
| GET | /api/v1/threads | list conversations — :109 |
| GET | /api/v1/threads/:id | thread + messages — :110 |
| POST | /api/v1/threads | create thread — :111 |
| PATCH | /api/v1/threads/:id/read | mark read — :112 |
| — | WebSocket | realtime via WsModule (planned) — :119 |
5. RBAC — GAP
No communication.* (or announcement.*) permissions exist in
permissions.constants.ts (grep: zero matches). Controller is JwtAuthGuard-only
(announcements.controller.ts:18). Closest reference: Notifications blueprint perms
notification.read / notification.send / notification.preference.manage
(studylyon-blueprint/04-Modules/Notifications.md:65-71). Client must not assume
compose/publish gating beyond auth; role gates are (planned).
6. Gap list (affects client)
| Gap | Implication |
|---|---|
No GET /announcements/:id | detail relies on carried list payload |
| No update / delete / archive routes | drafts cannot be edited; duplicates tolerated |
| No "mine" filter | client filters createdBy == me |
| No user-filter on list | client filters targetUserIds for "For me" |
| No pagination | unbounded list; (planned) when tenant grows |
| No priority/expiry fields | composer must not render them (08 §8) |
all audience ⇒ targetUserIds = [] | receipts counts must degrade (09 §3) |
No communication.* RBAC | authorship gating is client-side by createdBy for now |
| No event→queue map entries | downstream delivery (planned) |
13 — State Management (Communication Module)
- 1. State overview
- 2. AnnouncementFeedCubit
- 3. AnnouncementDetailCubit
- 4. AnnouncementComposerCubit
- 5. AnnouncementReadsCubit
- 6. Cross-cubit communication
- 7. Diagram
- 8. Rules
Cubit-per-screen state, following 00-shared/06. Server truth: announcement documents are immutable after publish (no update route); read state is derived from
readBy[].
1. State overview
| Cubit | Screen | Backend source | Mutation |
|---|---|---|---|
AnnouncementFeedCubit | Feed | GET /announcements | none (list) |
AnnouncementDetailCubit | Detail | carried item + POST :id/read | read receipt |
AnnouncementComposerCubit | Compose | POST /announcements, POST :id/publish | create, publish |
AnnouncementReadsCubit | Receipts | GET /announcements/:id/reads | none (stats) |
ThreadsCubit / ThreadCubit | Threads (secondary) | threads/messages APIs | send, mark read |
All Cubits take the repository/client via constructor injection; single
CommunicationRepository wraps the Dio client (see 15).
2. AnnouncementFeedCubit
States:
FeedInitial
FeedLoading
FeedLoaded(items: List<Announcement>, meId: String, tab: ForMe|All)
FeedError(message)
Events:
FeedFetch() → GET /announcements
FeedRefresh() → refetch
FeedTabChanged(tab) → local refilter (no fetch)
FeedItemRead(id) → optimistic unread-dot removal
- Filtering is client-side:
ForMe=audience.type == 'all' || meId ∈ targetUserIds(announcement.schema.ts:42-43; backend does not filter —announcement.service.ts:56-60). FeedItemReadis optimistic; the authoritativereadByarrives on next fetch — rollback on fetch when server disagrees (idempotent server-side anyway,announcement.repository.ts:20-33).- Caching: last
FeedLoadedkept in memory for offline banner rendering(forward-looking).
3. AnnouncementDetailCubit
States: DetailLoaded(item) | DetailReading | DetailRead | DetailError(message)
Events: DetailMarkRead() → POST /announcements/:id/read
- Single purpose: optimistic checkmark + fire-and-forget receipt POST (silent failure → snackbar; no state rollback needed since re-fetch reconciles).
- Auto-read on open
(proposed)lives here (policy flag from settings).
4. AnnouncementComposerCubit
States:
ComposerIdle(form: {title, body, audience, attachments})
ComposerDirty(form) → on any field change
ComposerSavingDraft → POST /announcements
ComposerDraftSaved(id) → snackbar, stay
ComposerPublishing(id) → POST create, then POST :id/publish
ComposerPublished(id) → pop to feed/mine
ComposerError(message, stage: draft|publish)
- Sequential pipeline: publish = create → publish (
08 §3); a draft-save failure cancels the pipeline; publish failure keeps the created draft id for retry. - Validation is a pure function over the form mirroring
create-announcement.dto.tsrules (08 §5). - No persistence across app restarts
(forward-looking: local draft store).
5. AnnouncementReadsCubit
States: ReadsLoading | ReadsLoaded(readBy, targetCount, tab: read|unread) | ReadsError
Events: ReadsFetch(id) | ReadsSegmentChanged(tab) | ReadsAutoRefresh() (proposed)
- Unread set computed:
targetUserIds − readBy.userId; requirestargetUserIdsfrom the feed item (reads API returns receipts only —announcement.service.ts:104-107). targetCount == 0(ALL audience) ⇒ count UI hidden (09 §3).
6. Cross-cubit communication
FeedItemReadandDetailMarkReaddo not share state directly; the feed reconciles on next fetch. Single source of truth = serverreadBy.- Composer publishes → feed/list must refresh on return (
AnnouncementFeedCubit.FeedRefresh()). - Deep links to detail need an item: resolve from feed cache, else fetch full list and
match id
(forward-looking)— noGET :idAPI (12 §6).
7. Diagram
flowchart LR
subgraph Server
API["GET /announcements"]
READ["POST /announcements/:id/read"]
PUB["POST /announcements<br/>+ :id/publish"]
RDS["GET /announcements/:id/reads"]
end
subgraph Client
FC[AnnouncementFeedCubit]
DC[AnnouncementDetailCubit]
CC[AnnouncementComposerCubit]
RC[AnnouncementReadsCubit]
end
FC -->|fetch/refresh| API
DC -->|mark read| READ
CC -->|create + publish| PUB
RC -->|receipts| RDS
DC -- "read reconciled on refetch" --> FC
CC -- "publish → refresh" --> FC
FC -- "carry item" --> DC
FC -- "carry item (targetUserIds)" --> RC
8. Rules
- Never store
readByas screen truth for other screens — onlyFeedLoadedcache. - Composer keeps its own form state; no redux-style global form.
- All API failures map to typed
ComposerError/FeedErrormessages, never raw exceptions. - Tenant/user ids (
meId) injected once at app bootstrap (auth session) — reused byForMefilter and read checks.
14 — QA Checklist (Communication Module)
- 1. Audience targeting
- 2. Draft / publish lifecycle
- 3. Read receipts
- 4. Delivery via events / queues
- 5. Expiry & priority
(planned) - 6. List / query behavior
- 7. Client states (per 06)
- 8. API contract conformance
- 9. Visual & a11y
- 10. Data integrity
Test scenarios across both backend (module spec) and client (this design). Every check maps to a source line. Severity: 🔴 blocker, 🟡 major, 🟢 minor.
1. Audience targeting
| # | Severity | Check | Expected / source |
|---|---|---|---|
| 1.1 | 🔴 | ROLE teacher resolves to active members only | org members roles: 'teacher', status: 'active' → userIds (announcement.service.ts:114-119); inactive excluded |
| 1.2 | 🔴 | GRADE by name resolves students of that grade | gradeRepo.findOne({name}) → studentRepo.find({gradeId}) (:120-125); unknown grade ⇒ [] |
| 1.3 | 🔴 | SECTION by name resolves students of that section | sectionRepo.findOne({name}) → students (:126-135) |
| 1.4 | 🟡 | CUSTOM with single string vs array | both handled (:136-143); non-array string wrapped to [value] |
| 1.5 | 🟡 | CUSTOM with invalid ObjectId | new Types.ObjectId(v) throws — unhandled ⇒ 500; gap (:142) — verify error UX |
| 1.6 | 🔴 | ALL type ⇒ targetUserIds = [] | :144-146 — UI must not render "0 recipients"; shows "Everyone" |
| 1.7 | 🔴 | Tenant isolation of audience resolution | grade/section lookups scoped by tenantId (repos) — no cross-tenant leakage |
| 1.8 | 🟢 | Typo'd grade/section name silently broadcasts to nobody | :122,129 — composer preview (planned); backend returns no warning |
| 1.9 | 🟢 | "For me" client filter matches server truth | all ⇒ always; else meId ∈ targetUserIds (announcement.schema.ts:42-43) |
2. Draft / publish lifecycle
| # | Severity | Check | Expected / source |
|---|---|---|---|
| 2.1 | 🔴 | Create ⇒ published: false, createdBy = token user | announcement.service.ts:31-37 |
| 2.2 | 🔴 | Draft never appears in recipient "For me" | client filter published (schema :45-46) |
| 2.3 | 🔴 | Publish idempotent — double publish no-op | early return when published (:68-70) |
| 2.4 | 🔴 | Publish sets publishedAt + resolved targetUserIds | :73-78 |
| 2.5 | 🟡 | Publish 404 for unknown id | NotFoundException (:62-66) |
| 2.6 | 🟡 | Draft cannot be edited (no update route) | recreate only — client dirty-guard, no false "edit" affordance |
| 2.7 | 🟢 | AnnouncementCreated emitted on draft save (not only publish) | :39-51 — downstream must tolerate draft events |
3. Read receipts
| # | Severity | Check | Expected / source |
|---|---|---|---|
| 3.1 | 🔴 | Read adds exactly one receipt per user | $addToSet (announcement.repository.ts:27-31) — idempotent |
| 3.2 | 🔴 | Receipt has userId + readAt | {userId, readAt: now} (:28; announcement.schema.ts:23-29) |
| 3.3 | 🔴 | Mark-read 404 | announcement.service.ts:100 |
| 3.4 | 🟡 | Duplicate read tap (double POST) no-ops | $addToSet + UI disabled while in flight |
| 3.5 | 🔴 | Reads endpoint returns receipts without targets | :104-107 — client must join targetUserIds from list payload |
| 3.6 | 🟡 | Unread count = targetUserIds − readBy.userId | derived client-side; test with partial reads |
| 3.7 | 🟢 | ALL-audience counts hidden | targetUserIds.length === 0 (:144-146) |
4. Delivery via events / queues
| # | Severity | Check | Expected / source |
|---|---|---|---|
| 4.1 | 🔴 | AnnouncementPublished emitted with {announcementId, title, audienceType} | announcement.service.ts:80-91; communication-events.ts:9-13 |
| 4.2 | 🔴 | Gap: event-queue map has no Announcement routes | event-queue-map.ts:6-43 — verify a consumer exists before claiming delivery; otherwise delivery is (planned) |
| 4.3 | 🟡 | correlationId, tenantId, actorId on events | :82-85 — audit continuity |
| 4.4 | 🟢 | Draft event (AnnouncementCreated) does not trigger recipient delivery | payload audienceType only; consumers must key on AnnouncementPublished |
5. Expiry & priority (planned)
| # | Severity | Check | Expected / source |
|---|---|---|---|
| 5.1 | 🟡 | Expiry field absent today — UI must not render it | schema has none (announcement.schema.ts:31-53) |
| 5.2 | 🟢 | When shipped: expired items hidden from feed, flagged on detail | design intent only |
| 5.3 | 🟢 | Priority/pinned ordering affects feed sort | today sort is createdAt: -1 only (announcement.service.ts:59) |
6. List / query behavior
| # | Severity | Check | Expected / source |
|---|---|---|---|
| 6.1 | 🟡 | ?audience= filter equals audience.type | filter['audience.type'] = audience (:58) |
| 6.2 | 🟡 | Sorted createdAt desc, tenant-scoped | :59 + BaseRepository scope (announcement.repository.ts:12-18) |
| 6.3 | 🟢 | Unbounded list (no pagination) — perf test with 1k+ docs | gap, (planned) |
| 6.4 | 🟢 | Index supports feed query | {tenantId, published, createdAt: -1} (announcement.schema.ts:57) |
7. Client states (per 06)
| # | Severity | Check |
|---|---|---|
| 7.1 | 🔴 | Feed: loading / empty / error-offline / loaded each render correctly |
| 7.2 | 🔴 | Offline: banner + last-fetch cache; mark-read queued and flushed (forward-looking) |
| 7.3 | 🟡 | Composer dirty-guard + double-tap publish protection |
| 7.4 | 🟡 | Publish failure keeps draft id; retry doesn't duplicate |
| 7.5 | 🟢 | Receipts sheet auto-refresh (30 s, proposed) doesn't reset scroll |
8. API contract conformance
| # | Severity | Check |
|---|---|---|
| 8.1 | 🔴 | Requests match CreateAnnouncementDto exactly (create-announcement.dto.ts:29-49) — no extra fields |
| 8.2 | 🟡 | Validation errors from class-validator rendered inline per field (00-shared/07) |
| 8.3 | 🟡 | 401 (JWT guard, announcements.controller.ts:11) → global login flow |
| 8.4 | 🟢 | Envelope (success/error) parsed per 00-shared/07 |
9. Visual & a11y
| # | Severity | Check |
|---|---|---|
| 9.1 | 🟡 | Unread dot + badge contrast in light and dark theme (11 §6) |
| 9.2 | 🟡 | No color-only semantics: draft/published/unread all have text labels |
| 9.3 | 🟢 | Composer errors announced via live region; focus moves to first invalid |
| 9.4 | 🟢 | Tablet two-pane feed/detail syncs selection correctly |
10. Data integrity
| # | Severity | Check |
|---|---|---|
| 10.1 | 🔴 | No cross-tenant reads (receipts of tenant A invisible to tenant B) |
| 10.2 | 🟡 | Soft-delete filter (BaseRepository) hides deleted announcements from all lists |
| 10.3 | 🟢 | title/body trimmed on save (announcement.schema.ts:33-37) |
15 — Flutter Implementation Guide (Communication Module)
- 1. Packages
- 2. Folder layout (module slice under
features/) - 3. Model (mirror the schema exactly)
- 4. Repository (API surface, from
12_API_Mapping) - 5. Feed Cubit (core shape)
- 6. Composer flow (sequential publish)
- 7. Read action (idempotent, silent-fail)
- 8. Audience picker (names not ids!)
- 9. Offline & resilience
(forward-looking) - 10. A11y & platform
- 11. Build order
- 12. Test matrix (unit/widget)
Build order, packages, folder layout, key code shapes. Client is forward-looking (not in PRD — 01 §2). Follows 00-shared/11 app architecture and 00-shared/06 state conventions.
1. Packages
| Package | Use |
|---|---|
flutter_bloc (+ equatable) | Cubits per 13_State_Management.md |
dio + retrofit (or hand-rolled client) | API client, envelope parsing (00-shared/07) |
intl | relative dates ("2d ago"), publishedAt formatting |
cached_network_image | attachment icons/thumbnails (planned) |
connectivity_plus | offline banner (10 §8) |
| No new state lib, no new DI lib | reuse app-wide choices (00-shared/11) |
2. Folder layout (module slice under features/)
lib/features/communication/
├── data/
│ ├── models/announcement.dart # mirrors announcement.schema.ts:31-53
│ ├── models/announcement_audience.dart
│ ├── models/read_receipt.dart # {userId, readAt}
│ └── communication_repository.dart # Dio calls → typed results
├── domain/ # (thin — skip unless app requires it)
├── application/
│ ├── announcement_feed_cubit.dart
│ ├── announcement_detail_cubit.dart
│ ├── announcement_composer_cubit.dart
│ └── announcement_reads_cubit.dart
└── presentation/
├── screens/announcement_feed_screen.dart
├── screens/announcement_detail_sheet.dart
├── screens/announcement_composer_screen.dart
├── screens/announcement_reads_sheet.dart
└── widgets/ # components from 07
├── announcement_card.dart
├── audience_chip.dart
├── announcement_status_badge.dart
├── read_receipt_bar.dart
├── read_receipt_row.dart
└── audience_picker.dart
3. Model (mirror the schema exactly)
enum AudienceType { all, role, grade, section, custom } // announcement.schema.ts:7-13
class AnnouncementAudience {
final AudienceType type;
final String? value; // string | string[] — model as StringListAudience if needed
}
class AnnouncementReadReceipt { final String userId; final DateTime readAt; }
class Announcement {
final String id, title, body;
final AnnouncementAudience audience;
final List<String> targetUserIds; // schema :42-43
final bool published; // :45-46
final DateTime? publishedAt; // :48-49
final List<AnnouncementReadReceipt> readBy; // :51-52
final List<String> attachments;
final String createdBy, createdAt, updatedAt;
}
Field names snake_case → camelCase per 00-shared/07 mapping convention.
4. Repository (API surface, from 12_API_Mapping)
class CommunicationRepository {
Future<List<Announcement>> listAnnouncements({AudienceType? audience}); // GET /announcements
Future<Announcement> createAnnouncement(CreateAnnouncementDto dto); // POST /announcements
Future<void> publishAnnouncement(String id); // POST :id/publish
Future<void> markAnnouncementRead(String id); // POST :id/read
Future<List<AnnouncementReadReceipt>> announcementReads(String id); // GET :id/reads
}
CreateAnnouncementDto mirrors create-announcement.dto.ts:29-49: title, body,
audience{type, value?}, attachments[]? — nothing else.
5. Feed Cubit (core shape)
class AnnouncementFeedCubit extends Cubit<AnnouncementFeedState> {
AnnouncementFeedCubit(this._repo, this._meId) : super(FeedInitial());
Future<void> fetch() async {
emit(FeedLoading());
try {
final items = await _repo.listAnnouncements();
emit(FeedLoaded(items: items, meId: _meId, tab: _currentTab));
} catch (e) {
emit(FeedError(message: friendly(e))); // typed errors, never raw
}
}
bool isForMe(Announcement a) =>
a.audience.type == AudienceType.all || a.targetUserIds.contains(_meId);
}
6. Composer flow (sequential publish)
Future<void> publish() async {
if (!valid(_form)) return emit(ComposerError(validation: _form.errors));
emit(ComposerSavingDraft());
try {
final draft = await _repo.createAnnouncement(dto(_form)); // POST → draft
emit(ComposerPublishing(id: draft.id));
await _repo.publishAnnouncement(draft.id); // POST :id/publish
emit(ComposerPublished(id: draft.id)); // pop + refresh feed
} on DioException catch (e) {
emit(ComposerError(stage: e.requestOptions.path.contains('/publish') ? 'publish' : 'draft',
message: friendly(e)));
}
}
7. Read action (idempotent, silent-fail)
void markRead(String id) {
emit(DetailRead()); // optimistic checkmark
unawaited(_repo.markAnnouncementRead(id) // POST :id/read — $addToSet safe
.catchError((_) => null)); // snackbar on error only; refetch reconciles
}
8. Audience picker (names not ids!)
Grade/section targeting uses names (announcement.service.ts:121,128). Widget must
offer options sourced from the academics module list (name strings), not free text;
custom uses a user-search chip flow. Show recipient preview (planned).
9. Offline & resilience (forward-looking)
- Feed: persist last
FeedLoadedin memory (orshared_preferencescache) for offline banner rendering. - Read receipts: local queue flushed on reconnect — safe because server dedupes
(
$addToSet,announcement.repository.ts:27-31). - No offline compose persistence in v1 (keep in-memory form only).
10. A11y & platform
- Unread dot:
Semantics(label: 'Unread', child: dot)— never color-only (11 §3). - Sheets use
showModalBottomSheetwithisScrollControlledfor receipts. - Tablet two-pane via
LayoutBuilder/NavigationRailpattern (00-shared/05). - Dates via
intlwith locale from app settings; relative time for feed rows.
11. Build order
- Models + repository (+ unit tests, mocked Dio).
- Feed Cubit + screen (loading/empty/error/offline states).
- Detail sheet + read action.
- Composer Cubit + form + audience picker (+ validation mirroring DTO).
- Receipts sheet (+ unread derivation).
- Threads list/detail (secondary, thin).
- WebSocket feed-update subscription
(planned)—IMPLEMENTATION_PLAN.md:119.
12. Test matrix (unit/widget)
| Cubit | Key tests |
|---|---|
| Feed | ForMe filter (all/role/grade/custom), error path, refresh reconcile |
| Detail | optimistic read, 404 snackbar, idempotent double-tap |
| Composer | DTO-field validation, publish pipeline failure keeps draft, dirty guard |
| Reads | unread derivation, ALL-audience count hide |
01 — Product Overview (CRM Module)
- 1. Purpose
- 2. Business goals
- 3. User goals (top level; per-role detail in 02_User_Personas.md)
- 4. Stakeholders
- 5. Why this exists
- 6. Platform dependencies (backend)
- 7. Success metrics (module)
- 8. Module edge cases
- 9. Module assumptions
- 10. Glossary (module)
StudyLyon CRM — lead capture, follow-up, conversion and admissions pipeline for multi-tenant institutions. Backend is the source of truth (
src/modules/crm/); shared platform context lives in 00-shared/01_Product_Overview.md.
1. Purpose
The CRM module turns school inquiries into enrolled students. It covers the entire inbound funnel:
- Lead capture — name, contact, source (
website | referral | walk_in | phone | campaign | other,lead.schema.ts:15-22) and an optional academic placement (grade/section/academicYear/class). - Nurture — status pipeline (
new → contacted → qualified → converted | closed,lead.schema.ts:7-13), follow-up records with scheduled/completed timestamps, and staff assignment (assignedTo,lead.schema.ts:79-80). - Conversion — promote a qualified lead to a User + Student record in one
action, recording
convertedAt/convertedToStudentId(crm.service.ts:118-176). - Campaigns — marketing campaigns that generate leads, with status, type,
dates, and metrics (
campaign.schema.ts:23-56). - Admissions — the formal application pipeline (
draft → submitted → documents_pending → interview_scheduled → under_review → approved | rejected | waitlisted → converted,admission.schema.ts:7-17) with document uploads, interviews, approver-tracked workflow history, and conversion to Student (admission.service.ts:39-285).
2. Business goals
| Goal | Measure |
|---|---|
| No inquiry lost | Every walk-in/phone/website lead lands in the pipeline with a follow-up date |
| Fast conversion | Lead → Student conversion one action after qualification (grade/year/class set) |
| Pipeline visibility | Funnel stats per status via admissions analytics; leads filterable by status |
| Accountable follow-ups | Every follow-up has scheduledAt, optional completedAt, and createdBy |
| Controlled admissions | Decisions only from decidable statuses; closed applications immutable (admission.service.ts:257-269) |
3. User goals (top level; per-role detail in 02_User_Personas.md)
- Receptionist: capture a walk-in/phone lead in under a minute; never lose paper slips.
- Admissions Officer/Counselor: work the queue by status, log follow-ups, qualify, convert; see the next follow-up due.
- Campaign Manager: register campaigns; attribute lead sources.
- Org Admin/Principal: funnel health (conversion rate, stale applications); oversight of approvals.
4. Stakeholders
Reception desk, admissions/counseling staff, marketing, principal/admin, applicants
(students/parents), interview panels, and system actors (EventBus consumers,
BullMQ admission-reminder / admission-expiry workers (planned),
IMPLEMENTATION_PLAN.md §1.3).
5. Why this exists
Institutions track inquiries in registers and WhatsApp threads. This module replaces that with a tenant-isolated, status-driven pipeline whose terminal state feeds the existing Users + Students modules — so a converted lead or approved admission becomes an enrolled student without re-keying.
6. Platform dependencies (backend)
- Modules:
UsersModule,StudentsModule(conversion),AuthModule(guard). - Tenant isolation: every collection scoped by
tenantId+ soft-delete filter (base.repository.ts:20-30). - Events:
LeadCreated,LeadConverted,AdmissionSubmitted,AdmissionApproved,AdmissionRejected,AdmissionConvertedvia EventBus (crm.service.ts:50-63,166-173;admission.service.ts:54-57,159-168,219-223). - Queues (planned):
admission-reminder,admission-expiryworkers (IMPLEMENTATION_PLAN.md §1.3 — reminder for incomplete docs/interview scheduling, auto-reject stale;findStalescan already exists atadmission.repository.ts:53-62). - Storage: document uploads reference
fileId(storage provider interface) — no upload endpoint in this module yet.
7. Success metrics (module)
- Lead capture completion < 60 s (reception cadence).
- Status pipeline entries per lead are prompt-driven (follow-up due = surfaced first).
- Duplicate-lead 409 path reached instead of double-entry.
- Conversion requires grade/year/class — zero student records created without placement.
- No application is decided from a non-deciable status (server-enforced).
8. Module edge cases
- Duplicate email on lead create → 409
DUPLICATE_RESOURCE(crm.service.ts:42-47). - Convert an already-converted or closed lead → 400 (
crm.service.ts:120-125). - Convert without grade/academicYear/class → 400 (
crm.service.ts:145-149). - Close a lead →
closedAtauto-set whenclosedReasonis absent (crm.service.ts:94-96); reopening is not supported. - Admission decision from a non-decidable status → 400 (
admission.service.ts:141-145). - Editing a closed admission (approved/rejected/waitlisted/converted) → 400
(
admission.service.ts:257-269). - Converting a non-approved admission → 400 (
admission.service.ts:174-178). - User create during conversion is best-effort: existing email → existing user reused
(
crm.service.ts:132-143;admission.service.ts:187-200). - Cross-tenant IDs → 404, never leaked (scoped filter).
9. Module assumptions
- PRD native-app exclusion (flagged):
PRODUCT_REQUIREMENTS_DOCUMENT.md:144puts native mobile apps out of Phase 1 scope. Decision with the product owner (see 00-shared/01 §9): these docs specify a full-featured Flutter client against the complete CRM API surface. Roadmap conflicts resolve in favor of these docs. - RBAC not yet enforced on endpoints: all
/api/v1/crm/*routes sit behindJwtAuthGuardonly (crm.controller.ts:29);crm.read/crm.lead.manage/crm.campaign.manage(permissions.constants.ts:34-36) exist but are not yet decorator-applied — permission checks are a(planned)enforcement item (IMPLEMENTATION_PLAN.md §5.1). - No lead/admission delete endpoints:
BaseRepository.softDelete(base.repository.ts:68-74) exists; exposing it is(planned). UI must not offer delete today. - Workers
(planned): reminder/expiry queues are spec'd but not implemented. - Push reminders
(forward-looking): no device registry exists (00-shared/01 §9); follow-up push notifications are forward-looking. - Lead pipeline terminology:
CLOSED(schema name) is rendered as "dropped/closed".
10. Glossary (module)
| Term | Meaning |
|---|---|
| Lead | An inquiry record (new/contacted/qualified/converted/closed) |
| Convert | Promote lead/admission → User + Student; sets convertedAt |
| Follow-up | {note, scheduledAt, completedAt?, createdBy, createdAt} subdocument |
| Campaign | Marketing source with type/status/dates/metrics |
| Admission | Formal application with document + interview + decision stages |
| Workflow stage | {from, to, approver?, comment?, at} — full admission state history |
| Decidable | submitted / documents_pending / interview_scheduled / under_review |
02 — User Personas (CRM Module)
- 1. Priya — Receptionist / Front Desk
- 2. Aditi — Admissions Officer / Counselor
- 3. Kabir — Campaign / Marketing Manager
- 4. Rohan — Org Admin / Principal
- 5. Sam — Applicant Parent (portal,
(planned)) - 6. Permission matrix (source of truth:
permissions.constants.ts:34-36)
Who uses the CRM, what they need, and which permissions map to them. Permissions taken verbatim from
permissions.constants.ts:34-36:crm.read,crm.lead.manage,crm.campaign.manage. Enforcement is(planned)— endpoints are JWT-gated only today (crm.controller.ts:29); UI gates on these permission strings in the meantime.
1. Priya — Receptionist / Front Desk
- Context: first point of contact for walk-ins and phone calls. Time-poor, interrupted constantly, often mid-conversation with a parent.
- Goals:
- Capture a lead while the parent is on the phone: name, contact, source, grade interest — nothing more.
- Never re-key the same parent (duplicate-email 409 path).
- Needs: a capture form with the minimum required fields (
firstName,lastName,email—create-lead.dto.ts:12-27), everything else optional; source defaulted towalk_in/phoneper screen entry. - Pain points: long forms; having to know grade/class codes; no delete button (good); uncertainty about "what happens next" after capture.
- Permissions:
crm.read,crm.lead.manage. - Success measure: capture < 60 s; zero abandoned captures.
2. Aditi — Admissions Officer / Counselor
- Context: owns the nurture pipeline. Works a queue of leads by status, does follow-up calls, qualifies, and converts. Runs the admission funnel for applicants.
- Goals:
- See "my leads" (assignedTo) and the next follow-up due per lead
(
followUps[].scheduledAt,lead.schema.ts:28-31). - Move status forward deliberately: contacted → qualified → converted; close uninterested leads with a reason.
- Convert in one action once grade/academicYear/class are set
(
crm.service.ts:145-149) — the student record appears automatically. - For admissions: upload documents, schedule interviews with panel + mode, decide approve/reject/waitlist with a comment.
- See "my leads" (assignedTo) and the next follow-up due per lead
(
- Pain points: a converted or closed lead cannot be re-converted
(
crm.service.ts:120-125); a closed admission is immutable (admission.service.ts:257-269) — decisions must be deliberate. - Permissions:
crm.read,crm.lead.manage(+crm.campaign.manageif also running campaigns). - Success measure: time from first contact to conversion visible and shrinking; no follow-up overdue.
3. Kabir — Campaign / Marketing Manager
- Context: runs open-house events, ads, referral drives; wants to know which source yields converts.
- Goals:
- Register campaigns (type: email/sms/social/print/other; status:
draft/active/paused/completed —
campaign.schema.ts:7-20) with dates. - See lead intake attributed to
source: campaignand per-campaign metrics (metrics.leadsGenerated/converted/sent/opened/clicked,campaign.schema.ts:45-52).
- Register campaigns (type: email/sms/social/print/other; status:
draft/active/paused/completed —
- Needs: campaign create form; no lead-source breakdown endpoint exists yet
(list is the only campaign read —
crm.controller.ts:77-81) → funnel attribution by source is(planned)/aggregation gap. - Permissions:
crm.campaign.manage,crm.read. - Success measure: every marketing initiative is a campaign record with dates.
4. Rohan — Org Admin / Principal
- Context: accountable for enrollment numbers; approves nothing day-to-day but watches the funnel.
- Goals:
- One screen of pipeline health: counts per admission status + conversion rate
(
GET /crm/admissions/stats,admission.service.ts:227-237). - Assurance that decisions carry approver + comment (
workflow[].approver,admission.schema.ts:59-60).
- One screen of pipeline health: counts per admission status + conversion rate
(
- Needs: read-only funnel tiles; audit of workflow history on any admission.
- Permissions:
crm.read(broad); write perms delegated to staff. - Success measure: funnel reviewed weekly; no surprises at enrollment time.
5. Sam — Applicant Parent (portal, (planned))
- Context: submitted an application; wants status without calling the office.
- Goals: see application status, submit required documents, see interview slot.
- Note: parent-portal read access to admissions is not in the API surface
(all routes are staff-facing under JWT) — portal experience is
(planned).
6. Permission matrix (source of truth: permissions.constants.ts:34-36)
| Permission | Receptionist | Counselor | Campaign Mgr | Admin |
|---|---|---|---|---|
crm.read | ✅ | ✅ | ✅ | ✅ |
crm.lead.manage | ✅ | ✅ | — | ✅ |
crm.campaign.manage | — | optional | ✅ | ✅ |
- Gap: no
crm.admission.*/crm.lead.delete/crm.followup.*permission strings exist; admission endpoints share the genericcrm.*set. Flag for RBAC design ((planned), IMPLEMENTATION_PLAN.md §5.1). - Enforcement
(planned): today any authenticated user can call every route.
03 — User Journey (CRM Module)
- 1. Journey: Walk-in lead capture → conversion (happy path)
- 2. Journey: Duplicate / already-in-system
- 3. Journey: Uninterested lead → closed
- 4. Journey: Admission application → enrollment (happy path)
- 5. Journey: Rejection / waitlist
- 6. Journey: Campaign-driven leads
- 7. Cross-cutting journey points
End-to-end journeys through the CRM. Steps marked
(planned)are indocs/IMPLEMENTATION_PLAN.mdbut not implemented;(forward-looking)depends on infrastructure that does not exist yet (device registry, WS topics).
1. Journey: Walk-in lead capture → conversion (happy path)
Actors: Priya (receptionist) → Aditi (counselor)
- Parent walks in asking about Grade 6. Priya opens New Lead, picks entry
walk_in(source default). - Fills
firstName,lastName,email(required), phone, grade interest → submit.- Server: duplicate email → 409 inline (
crm.service.ts:42-47); else lead createdstatus: new, emitsLeadCreated(crm.service.ts:50-63).
- Server: duplicate email → 409 inline (
- Priya hands off: Aditi opens lead detail, sets
assignedToto herself, logs a follow-up "Called; parent interested in June intake"scheduledAt: +2d. - Next day: leads list filtered by her assignment (
statusfilter exists; assignment filter(planned)) → she sees the lead, calls, setsstatus: contacted. - School visit happens; Aditi sets
status: qualified, adds grade/academicYear/class (lead.schema.ts:64-74). - Aditi taps Convert → confirm dialog → server creates User (+ Student,
ADM{timestamp}admission number) and marks leadconvertedwithconvertedToStudentId(crm.service.ts:151-164), emitsLeadConverted(crm.service.ts:166-173). - Lead list: lead now shows converted badge; detail links to student.
2. Journey: Duplicate / already-in-system
- Receptionist types an email that already exists → 409 on submit
(
crm.service.ts:42-47). - UI shows "A lead with this email already exists" + actions: open existing lead /
edit fields. No merge endpoint exists — merge is
(planned).
3. Journey: Uninterested lead → closed
- Counselor sets
status: closed(+ optionalclosedReason). - Server auto-stamps
closedAtwhen no reason given (crm.service.ts:94-96). - Closed leads remain visible under the status filter (no hard delete anywhere;
soft-delete API
(planned),base.repository.ts:68-74). - Convert action is disabled for closed leads (server 400 guard,
crm.service.ts:123-125).
4. Journey: Admission application → enrollment (happy path)
Actors: applicant (submitted via office) → Aditi → interview panel → admin
- Office submits application (POST
/crm/admissions) → statussubmitted, workflow recordsdraft → submitted(admission.service.ts:39-59), emitsAdmissionSubmitted. - Aditi adds required documents (TC/marksheet/certificate/photo —
admission.schema.ts:27-33) → status auto-advances todocuments_pending(admission.service.ts:112-114). - Aditi schedules interview (datetime, panel staff, mode online/offline, feedback
later) → status
interview_scheduled(admission.service.ts:118-134). - Panel records feedback; Aditi moves status
under_review. - Decision: Approve with comment → status
approved,decidedAtset, emitsAdmissionApproved(admission.service.ts:136-170). Workflow history now shows approver + comment per hop (admission.schema.ts:52-67). - Admin/Aditi converts → User + Student created, status
converted+conversion{studentId, convertedAt}(admission.service.ts:172-225), emitsAdmissionConverted. - Reminder automation
(planned): reminder worker nudges incomplete docs; expiry worker auto-rejects stale (findStale,admission.repository.ts:53-62; IMPLEMENTATION_PLAN.md §1.3).
5. Journey: Rejection / waitlist
- Decision
rejectorwaitlistfrom a decidable status (ADMISSION_DECIDABLE_STATUSES,admission.schema.ts:20-25). - Status becomes terminal-immutable: further edits blocked
(
admission.service.ts:257-269); convert blocked unlessapproved(admission.service.ts:174-178). - Emits
AdmissionRejected(reject only — waitlist emits no event today).
6. Journey: Campaign-driven leads
- Kabir creates campaign (name, type, status, dates) —
POST
/crm/campaigns(crm.controller.ts:83-87). - Website/print leads arrive with
source: campaign(lead.schema.ts:58-59). - Kabir views campaign list (
crm.controller.ts:77-81) — no per-campaign lead drill-down today; source-based funnel analytics(planned). metrics(campaign.schema.ts:45-52) are schema-only; no write endpoint exists → metrics are(planned).
7. Cross-cutting journey points
- Search
(planned): leads join global search (IMPLEMENTATION_PLAN.md §3). - Bulk import of leads
(planned)(IMPLEMENTATION_PLAN.md §3). - Push follow-up reminders
(forward-looking): no device registry; WS has no CRM topics — the client polls; reminders surface only in-app today. - Coaching conversion
(planned): Lead → batch enrollment flow (not class/section) for coaching institutions (IMPLEMENTATION_PLAN.md §6.6).
04 — Information Architecture (CRM Module)
- 1. Navigation model
- 2. Information hierarchy — Lead
- 3. Information hierarchy — Admission
- 4. Information hierarchy — Campaign
- 5. Cross-entity relations
- 6. Filter & sort contract
- 7. Labels & status vocabulary (single source)
Screen tree, navigation, and data entities for the CRM client. Global navigation conventions: 00-shared/05_Global_Information_Architecture.md. Routes shown as
/crm/...under the authenticated shell.
1. Navigation model
The CRM is one top-level destination with three tabs and one sub-flow:
CRM (/crm)
├── Leads tab /crm/leads
│ ├── Lead detail /crm/leads/:id
│ │ ├── Lead editor (sheet) /crm/leads/:id/edit (S3)
│ │ ├── Follow-up composer (sheet) (S2 inline)
│ │ └── Convert flow (dialog + states) (S4)
│ └── New lead /crm/leads/new (S3)
├── Admissions tab /crm/admissions
│ ├── Admission detail /crm/admissions/:id
│ │ ├── Documents upload (sheet) (S6 inline)
│ │ ├── Interview scheduler (sheet) (S8)
│ │ └── Decision dialog (S9)
│ └── New admission /crm/admissions/new (S7)
└── Campaigns tab /crm/campaigns
└── New campaign /crm/campaigns/new (S11)
- Master-detail: on tablets the tab list and detail render side-by-side; on phones detail is a pushed route.
- Permission-gated nodes: writes (
crm.lead.manage,crm.campaign.manage) hide FABs/actions; reads needcrm.read(permissions.constants.ts:34-36). Enforcement on server is(planned)— client gates anyway.
2. Information hierarchy — Lead
Lead
├── Identity: firstName, middleName, lastName, email, phone
├── Pipeline: status (new/contacted/qualified/converted/closed)
│ closedAt, closedReason, convertedAt, convertedToStudentId
├── Source: source (website/referral/walk_in/phone/campaign/other)
├── Placement: gradeId, sectionId, academicYearId, classId
├── Ownership: assignedTo (Staff ref), notes
├── Follow-ups: [{note, scheduledAt, completedAt?, createdBy, createdAt}] (timeline)
└── metadata (opaque)
Fields per lead.schema.ts:41-99; BaseSchema adds tenantId/createdAt/updatedAt/version.
Derived values (client-computed, no dedicated field): next follow-up due =
earliest followUps[].scheduledAt without completedAt; "overdue" if due < now.
Gap: no nextContactDate column exists — see 08_Form_Specifications.md §5.
3. Information hierarchy — Admission
Admission
├── Identity: firstName, middleName, lastName, email, phone
├── Pipeline: status (9 states, admission.schema.ts:7-17), submittedAt, decidedAt
├── Placement: gradeId, academicYearId, classId
├── Documents: [{type, fileId, filename, uploadedBy, uploadedAt}]
├── Interview: {scheduledAt, panel[], mode, feedback}
├── Workflow: [{from, to, approver?, comment?, at}] ← the audit trail
├── Conversion: {studentId, convertedAt}
└── notes
4. Information hierarchy — Campaign
Campaign
├── name, description
├── type (email/sms/social/print/other), status (draft/active/paused/completed)
├── startDate, endDate
├── targetAudience (opaque)
└── metrics {leadsGenerated, converted, sent, opened, clicked} ← schema-only (planned write path)
5. Cross-entity relations
| Relation | Edge | Source |
|---|---|---|
| Lead → Student | convertedToStudentId after convert | lead.schema.ts:88-89 |
| Lead → Staff | assignedTo | lead.schema.ts:79-80 |
| Lead → Academic | gradeId/sectionId/academicYearId/classId refs | lead.schema.ts:64-74 |
| Lead → Campaign | implicit via source: campaign (no FK) | lead.schema.ts:58-59 |
| Admission → Student | conversion.studentId after convert | admission.schema.ts:83-89 |
| Admission → Staff | interview.panel[], workflow[].approver | admission.schema.ts:59-60,73-74 |
Client note: lead/admission payloads carry raw ObjectIds for refs; detail screens
resolve display names via the relevant module (students, staff, academics) —
cross-module GETs are (planned) where endpoints are missing.
6. Filter & sort contract
| List | Server filters | Server sort (fixed) | Source |
|---|---|---|---|
| Leads | status only (page/limit) | createdAt: -1 | crm.controller.ts:37-45; lead.repository.ts:31 |
| Admissions | status only (page/limit) | submittedAt: -1 | crm.controller.ts:103-111; admission.repository.ts:29 |
| Campaigns | none (page/limit) | createdAt: -1 | crm.controller.ts:77-81; campaign.repository.ts:25 |
- Gaps: no
assignedTo,source,q(search), orsortclient param on any CRM list. Client filters (my leads, source chip) must be client-side over one page or wait for(planned)server filters. Global search covers leads(planned)(IMPLEMENTATION_PLAN.md §3). - Pagination meta:
{page, limit, totalItems, totalPages, hasNext, hasPrevious}(00-shared/07 §2;buildPaginationMeta).
7. Labels & status vocabulary (single source)
- Lead statuses render as: New / Contacted / Qualified / Converted / Closed ("Closed" = dropped in CRM terms).
- Sources: Website / Referral / Walk-in / Phone / Campaign / Other.
- Admission statuses: Draft / Submitted / Documents pending / Interview scheduled / Under review / Approved / Rejected / Waitlisted / Converted.
- Decision actions: Approve / Reject / Waitlist (
admission-decision.dto.ts:4-8).
05 — Screen Inventory (CRM Module)
- 1. Leads List (
/crm/leads) - 2. Lead Detail (
/crm/leads/:id) - 3. Lead Editor — Create / Edit (
/crm/leads/new, sheet on detail) - 4. Convert Lead Flow (dialog from detail) — S4
- 5. Admissions List (
/crm/admissions) - 6. Admission Detail (
/crm/admissions/:id) - 7. Admission Editor — Create (
/crm/admissions/new) — S7 - 8. Interview Scheduler (sheet on S6)
- 9. Decision Dialog (on S6)
- 10. Campaigns List (
/crm/campaigns) - 11. Campaign Editor — Create (
/crm/campaigns/new) — S11 - Shared components used
- Analytics events
(proposed) - Keyboard, landscape, tablet, desktop
Every CRM screen, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics. States = idle / loading / success / empty / error(offline, 409, 400) / disabled / permission. Analytics events follow
{module}.{screen}.{action}(proposed).
1. Leads List (/crm/leads)
| Field | Detail |
|---|---|
| Purpose | Work the lead pipeline by status; open details; create leads |
| Entry | CRM tab; back from detail |
| Source | GET /crm/leads?page&limit&status (crm.controller.ts:37-45) |
| Filters | status dropdown (server); source + "my leads" chips (client-side, per 04 §6) |
| Primary CTA | FAB New lead (crm.lead.manage) |
| Row | avatar initials, name, email/phone, source icon, status chip, next follow-up due |
| States | loading skeleton; empty ("No leads — create your first"); error(offline/5xx) + retry; 403 permission-gated tab |
| Pull-to-refresh | RefreshIndicator bypasses cache |
| Sort | server-fixed createdAt desc; client secondary sort (name) (proposed) |
| Analytics | crm.leads.list, crm.leads.filter(status), crm.leads.open(id) |
2. Lead Detail (/crm/leads/:id)
| Field | Detail |
|---|---|
| Purpose | Full lead record: identity, status, placement, notes, follow-up timeline, convert |
| Source | GET /crm/leads/:id (crm.controller.ts:53-57) |
| Sections | identity card · pipeline card (status chip + timestamps) · placement card · follow-up timeline (chronological, lead.schema.ts:24-39) · notes |
| Actions | Edit (sheet S3), Add follow-up (sheet), Convert to student (S4, gated by rules crm.service.ts:120-125,145-149) |
| Timeline | followUps with completedAt checkmark; conversion entry when converted |
| States | loading; 404 ("Lead not found"); permission mode read-only |
| Analytics | crm.lead.open, crm.lead.followup.added, crm.lead.converted |
3. Lead Editor — Create / Edit (/crm/leads/new, sheet on detail)
| Field | Detail |
|---|---|
| Purpose | Capture (min fields) or update a lead |
| Sources | create: POST /crm/leads (crm.controller.ts:47-51); edit: PATCH /crm/leads/:id (crm.controller.ts:59-63) |
| Form | 08_Form_Specifications.md §2 (13 fields, 3 required) |
| States | 409 duplicate-email inline (crm.service.ts:42-47); 400 field errors |
| Motion | modal sheet; error shake; success → detail refresh |
| Analytics | crm.lead.create.{submit,success,duplicate}, crm.lead.update.{submit,success} |
4. Convert Lead Flow (dialog from detail) — S4
| Field | Detail |
|---|---|
| Purpose | One-action promotion of lead → User + Student |
| API | POST /crm/leads/:id/convert (crm.controller.ts:71-75) |
| Pre-check (client) | status not converted/closed; gradeId+academicYearId+classId present (else 400 server-side, crm.service.ts:145-149) |
| Confirm | dialog: "Create student account from this lead?" (irreversible) |
| Result | success → converted badge + link to student; error 400 → inline rule list |
| Analytics | crm.lead.convert.{submit,success,failed(rule)} |
5. Admissions List (/crm/admissions)
| Field | Detail |
|---|---|
| Purpose | Manage the application funnel by status; funnel stats header |
| Source | GET /crm/admissions?page&limit&status (crm.controller.ts:103-111) |
| Header tiles | counts + conversion rate from GET /crm/admissions/stats (crm.controller.ts:97-101; admission.service.ts:227-237) |
| Row | name, email, status badge, submittedAt, pending-documents hint |
| CTA | FAB New application |
| States | as S1; stats tile shows its own loading/skeleton |
| Analytics | crm.admissions.list, crm.admissions.filter(status), crm.admissions.stats.view |
6. Admission Detail (/crm/admissions/:id)
| Field | Detail |
|---|---|
| Purpose | Application record: identity, status, documents, interview, workflow history, decision actions |
| Source | GET /crm/admissions/:id (crm.controller.ts:113-117) |
| Sections | identity card · status + submittedAt/decidedAt · documents list (type chips + filename) · interview card · workflow timeline (from→to, approver, comment, at — admission.schema.ts:52-67) · conversion card (when converted) |
Actions (decidable only, admission.schema.ts:20-25) | Add document, Schedule interview, Make decision; Convert only when approved |
| Read-only modes | closed statuses block edit/decision UI (server also 400s, admission.service.ts:257-269) |
| Analytics | crm.admission.open, crm.admission.document.added, crm.admission.interview.scheduled, crm.admission.decided, crm.admission.converted |
7. Admission Editor — Create (/crm/admissions/new) — S7
| Field | Detail |
|---|---|
| Purpose | Submit an application on behalf of applicant |
| Source | POST /crm/admissions (crm.controller.ts:91-95) → status always submitted (admission.service.ts:43) |
| Form | 08 §3 (name/email/phone/placement/notes) |
| Note | no edit route for draft: server sets SUBMITTED directly; updates via PATCH only while open (admission.service.ts:81-93) |
| Analytics | crm.admission.create.{submit,success} |
8. Interview Scheduler (sheet on S6)
| Field | Detail |
|---|---|
| Purpose | Set interview slot, panel, mode; optional feedback |
| Source | POST /crm/admissions/:id/schedule-interview (crm.controller.ts:134-141) → status interview_scheduled (admission.service.ts:130-132) |
| Inputs | scheduledAt*, panel[] (staff multi-pick), mode online/offline, feedback |
| Analytics | crm.admission.interview.scheduled |
9. Decision Dialog (on S6)
| Field | Detail |
|---|---|
| Purpose | Approve / Reject / Waitlist with optional comment |
| Source | POST /crm/admissions/:id/decision (crm.controller.ts:143-147; admission.service.ts:136-170) |
| Guard | only from decidable statuses; 400 otherwise (admission.service.ts:141-145) |
| Confirm | reject/waitlist confirm copy ("reversible? no — closed once decided") |
| Analytics | crm.admission.decided{approve,reject,waitlist} |
10. Campaigns List (/crm/campaigns)
| Field | Detail |
|---|---|
| Purpose | See marketing campaigns; create new |
| Source | GET /crm/campaigns?page&limit (crm.controller.ts:77-81) |
| Row | name, type icon, status badge, dates, metrics snapshot (leadsGenerated etc.) |
| CTA | FAB New campaign (crm.campaign.manage) |
| Gap | no campaign detail/edit/delete endpoints — row tap is informational-only today |
| Analytics | crm.campaigns.list, crm.campaign.open |
11. Campaign Editor — Create (/crm/campaigns/new) — S11
| Field | Detail |
|---|---|
| Purpose | Register a campaign (name*, description, type, status, start/end date) |
| Source | POST /crm/campaigns (crm.controller.ts:83-87; create-campaign.dto.ts:5-34) |
| Analytics | crm.campaign.create.{submit,success} |
Shared components used
AppTextField, AppButton, AppSnackbar, AppCard, AppListTile, AppBottomSheet,
AppDialog, AppMenu, AppSkeleton, AppEmptyState, AppOfflineBanner, AppFAB,
AppChips, AppBadge, AppAvatar, AppSegmentedButton, AppDatePicker,
AppTimeline. Module-specific: LeadStatusChip, LeadSourceIcon, FollowUpCard,
StatusBadge, WorkflowTimeline, DocumentCard, InterviewCard, FunnelStatCard,
ConvertDialog — defined in 07_Component_Library.md.
Analytics events (proposed)
crm.lead.{list,filter,open,create.*,update.*,followup.added,convert.*},
crm.admission.{list,filter,open,create.*,document.added,interview.scheduled,decided,converted,stats.view},
crm.campaign.{list,open,create.*} (all proposed).
Keyboard, landscape, tablet, desktop
- Forms: keyboard-avoid +
ScrollView; email →.emailAddress; phone →.phone; dates via picker. - Tablet/desktop: two-pane list/detail (S1↔S2, S5↔S6); dialogs stay centered cards.
- Timeline and workflow lists scroll independently on tablet detail pane.
06 — Screen Specifications (CRM Module)
- S1 — Leads List (
/crm/leads) - S2 — Lead Detail (
/crm/leads/:id) - S3 — Lead Editor (Create
/crm/leads/new· Edit sheet/crm/leads/:id/edit) - S4 — Convert Lead Dialog (from S2)
- S5 — Admissions List (
/crm/admissions) - S6 — Admission Detail (
/crm/admissions/:id) - S7 — Admission Editor — Create (
/crm/admissions/new) - S8 — Interview Scheduler (sheet on S6)
- S9 — Decision Dialog (on S6)
- S10 — Campaigns List (
/crm/campaigns) - S11 — Campaign Editor — Create (
/crm/campaigns/new)
Detailed specifications for every screen in 05_Screen_Inventory.md. Layout is mobile-first; tablet/desktop adaptations noted per screen. All states, copy, and API sources derive from
src/modules/crm/(file:line cited). Shared atoms from 00-shared/02-04 (Design Tokens, Component Library, Design System Mapping).
S1 — Leads List (/crm/leads)
Purpose & entry
Primary working screen for counselors. Entry: CRM tab, back from S2. Exit: S2 (row), S3 (FAB), filtered list.
Layout (mobile)
[AppBar: "Leads" | search icon (planned) | overflow]
[Status filter bar: All | New | Contacted | Qualified | Converted | Closed]
← segmented chips, horizontal scroll
[Source chips row (client-side filter): All | Website | Referral | Walk-in | Phone | Campaign | Other]
[My leads toggle chip (client-side, assignedTo == me)]
[ListView.builder of LeadListTile]
[FAB: New lead]
Data contract
GET /crm/leads?page=1&limit=20&status=new(crm.controller.ts:37-45); response{data: Lead[], meta: {page, limit, totalItems, totalPages, hasNext, hasPrevious}}.- Status values:
new|contacted|qualified|converted|closed(lead.schema.ts:7-13). - Server sort fixed
createdAt: -1(lead.repository.ts:31).
Row composition
LeadListTile: 40 dp AppAvatar (initials of firstName/lastName), title = full name,
subtitle = email (or phone if no email) + "next follow-up 12 Aug" derived from earliest
uncompleted followUps[].scheduledAt (lead.schema.ts:28-31), trailing =
LeadStatusChip + LeadSourceIcon. Converted rows show a check icon instead of the
action hint. Overdue follow-ups render subtitle in color.error with an alert icon.
States
| State | UI |
|---|---|
| initial/loading | 8 × AppSkeleton list rows |
| empty (filtered) | AppEmptyState "No leads in this status" + clear-filter action |
| empty (all) | AppEmptyState "No leads yet — create your first lead" + CTA to S3 |
| error offline | AppOfflineBanner + cached list (SWR, 00-shared/06 §3.3) |
| error 5xx | AppErrorState(code, message, retry) |
| permission | tab hidden without crm.read (route guard) |
Interactions
- Status chip tap → server request with
statusparam; chips reflect loading per-tab (keep list, show linear progress). - Source / "My leads" chips → client-side filter of current page (no server support —
04 §6gap;(planned)server filters). - Infinite scroll:
loadMore()whenhasNext(00-shared/06 §3.2). - Pull-to-refresh bypasses cache.
- Realtime
(forward-looking): no WS topic for lead changes — no live updates; refresh on foreground resume.
a11y
Row = single semantics label: "Aditi Sharma, contacted, follow-up due 12 Aug".
Status chips announce state + count. Contrast for overdue text ≥ 4.5:1 (00-shared/09).
Motion
List fade-slide 150 ms; chip selection m-fast; FAB press scale.
Analytics (proposed)
crm.leads.list, crm.leads.filter(status|source|mine), crm.leads.open(id),
crm.leads.load_more.
S2 — Lead Detail (/crm/leads/:id)
Purpose
The lead's full record + follow-up timeline + convert entry point.
Layout (scroll: CustomScrollView with slivers)
- Identity header (sticky):
AppAvatar56 dp, name, email, phone,LeadSourceIcon- label,
LeadStatusChip.
- label,
- Pipeline card (
AppCard):- status label + updatedAt
- if converted:
convertedAt, "Student #…" link (convertedToStudentId,lead.schema.ts:85-89) - if closed:
closedAt+closedReason(lead.schema.ts:91-95)
- Placement card: grade / section / academic year / class labels (resolved from
refs; unresolved → "—"). Empty state: "Add grade, academic year and class to enable
conversion" (mirrors server rule
crm.service.ts:145-149). - Assignment card:
assignedTostaff name or "Unassigned" + assign action (PATCHassignedTo,update-lead.dto.ts:72-75). - Follow-up timeline (module
AppTimeline):- each entry: note,
scheduledAt(formatted), checkmark whencompletedAtset, "by {createdBy name}" (lead.schema.ts:24-39) - overdue entries in
color.error; today's incolor.primary - empty state: "No follow-ups yet"
- each entry: note,
- Notes card:
notesor empty. - Bottom action bar (sticky,
crm.lead.manage):Add follow-up,Edit,Convert to student(primary, disabled when converted/closed or placement incomplete — tooltip explains rule).
Data contract
GET /crm/leads/:id (crm.controller.ts:53-57) → 404 RESOURCE_NOT_FOUND for
missing/cross-tenant (scoped filter, base.repository.ts:20-30).
States
loading skeleton; 404 → AppErrorState "Lead not found" + back; offline → cached
detail (no client cache per 00-shared/06 §3.3 — detail views fetch; banner + retry).
Interactions
- Convert tap → S4 dialog (rules pre-checked client-side).
- Follow-up add → bottom sheet (S2-inline form,
08 §2b), POSTcrm.controller.ts:65-69, appends to timeline on success (pessimistic — server response is authoritative). - Edit → S3 sheet; on save PATCH (
crm.controller.ts:59-63) → refresh detail. - Assignment change → PATCH, optimistic with rollback (
00-shared/06 §3.5).
a11y
Timeline announced per entry with date; convert button disabled state explained via
Tooltip + semantics hint.
Motion
Section reveal m-base; timeline entries stagger 40 ms.
Analytics (proposed)
crm.lead.open(id), crm.lead.followup.added, crm.lead.assigned,
crm.lead.update.completed, crm.lead.convert.opened.
S3 — Lead Editor (Create /crm/leads/new · Edit sheet /crm/leads/:id/edit)
Purpose & entry
Minimal capture (create) / full edit (edit). Create entered from S1 FAB; edit from S2 action bar.
Layout
Bottom sheet (phone) / centered dialog ≤ 560 dp (tablet, 00-shared/05). Scrollable
form: 3 required fields first (firstName, lastName, email), then optional groups
(contact, source, status, placement, assignment, notes). Submit = primary filled
button full width; secondary "Cancel".
Data contract
- Create:
POST /crm/leads(crm.controller.ts:47-51) — payload percreate-lead.dto.ts:11-73. - Edit:
PATCH /crm/leads/:id(crm.controller.ts:59-63) — partial fields only; server merges defined fields (crm.service.ts:88-100);versionincrements (base.repository.ts:63). - Duplicate email → 409
DUPLICATE_RESOURCE(crm.service.ts:42-47).
Field table (exact, per DTO)
| Field | Required | Type/validation | Default | Source |
|---|---|---|---|---|
| firstName | ✅ | string | — | create-lead.dto.ts:12-14 |
| middleName | — | string | — | :16-19 |
| lastName | ✅ | string | — | :21-23 |
| ✅ | @IsEmail, lowercased server-side | — | :25-27; lead.schema.ts:52-53 | |
| phone | — | string | — | :29-32 |
| source | — | enum website/referral/walk_in/phone/campaign/other | website | :34-37; lead.schema.ts:58-59 |
| status | — | enum new/contacted/qualified/converted/closed | new | :39-42; lead.schema.ts:61-62 |
| gradeId | — | MongoId | — | :44-47 |
| sectionId | — | MongoId | — | :49-52 |
| academicYearId | — | MongoId | — | :54-57 |
| classId | — | MongoId | — | :59-62 |
| notes | — | string (multiline) | — | :64-67 |
| assignedTo | — | MongoId (staff) | — | :69-72 |
Behaviors
- Create:
statushidden by default (server defaultsnew); exposed under "Advanced" — admission staff may pre-set contacted/qualified. - Status rules (mirror server): setting
convertedvia form is blocked — must use convert flow (server guards,crm.service.ts:120-125); settingclosedwithoutclosedReason→ client shows reason field (server auto-stampsclosedAt,crm.service.ts:94-96). - 409 handling: inline banner "A lead with this email already exists" + actions
"View existing" (navigates to existing lead, requires server lookup
(planned)— no email-search endpoint; today: user edits email) / "Edit details". - 400: per-field errors under fields (
00-shared/06 §5). - Double-submit guard: submit disabled while in-flight (
00-shared/08 §6).
Keyboard & a11y
Email .emailAddress, phone .phone, next-field tab order; labels linked; first
invalid field focused on error; live region for 409 banner.
Motion
Sheet slide-up m-base; error shake; success → close + snackbar "Lead created".
Analytics (proposed)
crm.lead.create.submit|success|duplicate, crm.lead.update.submit|success.
S4 — Convert Lead Dialog (from S2)
Purpose
One-action promotion of a qualified lead to User + Student. Irreversible.
Layout
AppDialog: title "Convert to student?", body summary (name, email, grade/year/class),
warning list of pre-conditions, actions Cancel / Convert (filled, destructive-tone
color.primary). Progress: in-dialog spinner + "Creating user and student record…".
Data contract
POST /crm/leads/:id/convert (crm.controller.ts:71-75).
- 400 "Lead is already converted." (
crm.service.ts:120-122) - 400 "Cannot convert a closed lead." (
crm.service.ts:123-125) - 400 "Lead must have grade, academic year, and class assigned for conversion."
(
crm.service.ts:145-149) - Success: lead becomes
converted,convertedAt,convertedToStudentId(crm.service.ts:161-164); emitsLeadConverted(crm.service.ts:166-173).
Behaviors
- Client pre-check disables Convert with tooltip when a rule fails — server 400s are still handled (rule drift) with the exact server message mapped to i18n.
- No optimistic update (side effects — user/student creation;
00-shared/06 §3.5). - On success: dialog → success check → S2 refreshes showing converted badge + student link; snackbar "Student created".
a11y
Dialog announced; pre-condition list read in order; focus lands on Cancel.
Analytics (proposed)
crm.lead.convert.submit, crm.lead.convert.success, crm.lead.convert.failed(rule).
S5 — Admissions List (/crm/admissions)
Purpose
Application funnel working screen with header stats.
Layout (mobile)
[AppBar: "Admissions"]
[FunnelStatRow: 3-4 stat cards (Submitted | Pending docs | Approved | Conversion rate)]
[Status filter chips: All + 9 statuses, horizontal scroll]
[ListView.builder of AdmissionListTile]
[FAB: New application]
Stat cards source: GET /crm/admissions/stats (crm.controller.ts:97-101) → counts
per status + total + conversionRate (admission.service.ts:227-237). Tiles show
skeleton while loading; failure → tile shows "—" (non-blocking).
Row composition
Name (title), email + submittedAt (subtitle), trailing StatusBadge; document-pending
applications get a small "2 docs missing" hint only where documents list known
(client-side count from documents, admission.schema.ts:124-125 — actual required
set is (planned)).
Data contract
GET /crm/admissions?page&limit&status (crm.controller.ts:103-111); sort fixed
submittedAt: -1 (admission.repository.ts:29).
States & interactions
Same pattern as S1: skeleton/empty/error/offline; status chips server-driven;
pull-to-refresh refreshes list and stats. Realtime (forward-looking): no WS
topic; foreground-resume refetch.
a11y / motion
As S1; stat cards are single semantics labels.
Analytics (proposed)
crm.admissions.list, crm.admissions.filter(status), crm.admissions.stats.view.
S6 — Admission Detail (/crm/admissions/:id)
Purpose
The application record with documents, interview, workflow audit trail, and decidable/convert actions.
Layout (scroll)
- Identity header: name, email, phone,
StatusBadge. - Pipeline card: status, submittedAt, decidedAt (
admission.schema.ts:136-143). - Placement card: grade/academicYear/class (resolved).
- Documents card: rows of
DocumentCard(type chip — tc/marksheet/certificate/ photo/other,admission.schema.ts:27-33— filename, uploadedBy, uploadedAt) +Add documentaction (crm.lead.manage). - Interview card: scheduledAt, mode (online/offline), panel names, feedback
(
admission.schema.ts:69-81);Scheduleaction when none / editable. - Workflow timeline (module
AppTimeline, authoritative audit trail): each stage "from → to" (admission.schema.ts:52-67), approver name, comment, timestamp — read-only, rendered bottom-up. - Conversion card (when converted):
conversion.studentId+convertedAt(admission.schema.ts:83-89) + student link. - Notes card.
- Bottom action bar (contextual):
- status decidable (
ADMISSION_DECIDABLE_STATUSES,admission.schema.ts:20-25):Add document,Schedule interview,Make decision(primary) - status
approved:Convert to student(primary) - closed statuses: no action bar; read-only banner "This application is closed."
- status decidable (
Data contract
GET /crm/admissions/:id (crm.controller.ts:113-117); 404 for missing/cross-tenant.
Behaviors
- Every mutation re-fetches detail (workflow + status authoritative from server).
- Edit admission (PATCH,
crm.controller.ts:119-123) available while open — edits allowed on identity/placement/notes only viaEditaction; closed → 400 (admission.service.ts:257-269). - Decision/interview/document actions route to S8/S9 sheets (below).
a11y / motion
Workflow timeline announced per stage; decision primary button announced with rule
hint when disabled. Motion: section reveal m-base.
Analytics (proposed)
crm.admission.open, crm.admission.document.added, crm.admission.interview.scheduled,
crm.admission.edit.completed, crm.admission.decided.{approve,reject,waitlist},
crm.admission.converted.
S7 — Admission Editor — Create (/crm/admissions/new)
Purpose & layout
Application intake form (office-facilitated). Full-page scroll form (not sheet — longer form), groups: Applicant (firstName*, lastName*, email*, phone) · Placement (gradeId, academicYearId, classId) · Notes. Submit "Submit application".
Data contract
POST /crm/admissions (crm.controller.ts:91-95). Server forces status
submitted + workflow draft → submitted regardless of DTO (admission.service.ts:41-51)
— client shows "Application submitted" success state, not a draft.
Fields (exact, create-admission.dto.ts:4-46)
firstName*, lastName*, email* (@IsEmail), phone?, gradeId?, academicYearId?,
classId?, notes? — all placement refs MongoId.
Behaviors
- 400 per-field errors; 409 n/a (no uniqueness on admissions email today — duplicates allowed; flag as possible gap).
- Success → S5 list with snackbar + new admission visible (sort by submittedAt desc).
Analytics (proposed)
crm.admission.create.submit|success.
S8 — Interview Scheduler (sheet on S6)
Purpose
Set interview slot + panel + mode (+ optional feedback now or later via edit).
Layout
Bottom sheet: AppDatePicker+time (scheduledAt*), panel multi-select chips
(staff list (planned) source: staff module read), mode AppSegmentedButton
online/offline (default offline — admission.schema.ts:76-78), feedback textarea
(optional), actions Cancel / Schedule.
Data contract
POST /crm/admissions/:id/schedule-interview (crm.controller.ts:134-141;
schedule-interview.dto.ts:11-31). Server stores interview + transitions to
interview_scheduled when different (admission.service.ts:130-132).
Behaviors
- Re-schedule allowed while open (overwrites interview doc,
admission.service.ts:124-129). - No optimistic write; success → S6 interview card refreshes + status badge updates.
Analytics (proposed)
crm.admission.interview.scheduled.
S9 — Decision Dialog (on S6)
Purpose
Approve / Reject / Waitlist with optional comment. Terminal for the pipeline —
closed statuses become immutable (admission.service.ts:257-269).
Layout
AppDialog: three action choices (AppSegmentedButton or radio list: Approve /
Reject / Waitlist), comment textarea, warning line per choice
(reject: "Application closes permanently"), Cancel / Confirm decision.
Data contract
POST /crm/admissions/:id/decision (crm.controller.ts:143-147;
admission-decision.dto.ts:10-18). Rules:
- Only from decidable statuses → else 400 with exact message
(
admission.service.ts:141-145). - Sets status +
decidedAt(admission.service.ts:155); workflow stage appended with approver + comment (admission.service.ts:239-255). - Emits
AdmissionApproved/AdmissionRejected(admission.service.ts:158-168); waitlist emits none.
Behaviors
- Client pre-check: dialog only offered when decidable; server 400 mapped to i18n.
- Confirm requires explicit selection; default none.
- Pessimistic; success → S6 status + workflow refresh.
a11y
Warning copy per decision read by screen reader; focus on first choice.
Analytics (proposed)
crm.admission.decided.{approve,reject,waitlist}.
S10 — Campaigns List (/crm/campaigns)
Purpose
Marketing campaign registry.
Layout
AppBar "Campaigns" · ListView.builder of CampaignCard: name (title), type icon +
label, StatusBadge (draft/active/paused/completed — campaign.schema.ts:7-12),
date range, metrics line "12 leads · 3 converted" (from metrics,
campaign.schema.ts:45-52 — values schema-only; write path (planned), show "—"
when absent) · FAB "New campaign".
Data contract
GET /crm/campaigns?page&limit (crm.controller.ts:77-81); sort createdAt: -1
(campaign.repository.ts:25).
States & interactions
Standard skeleton/empty/error/offline; no detail route (no GET by id) — tap opens a read-only info bottom sheet (same data); row is informational. Pull-to-refresh.
Analytics (proposed)
crm.campaigns.list, crm.campaign.open.
S11 — Campaign Editor — Create (/crm/campaigns/new)
Purpose
Register a campaign.
Layout
Bottom sheet: name* (required, create-campaign.dto.ts:5-8), description (multiline),
type dropdown (email/sms/social/print/other, default email — campaign.schema.ts:30),
status dropdown (draft/active/paused/completed, default draft —
campaign.schema.ts:33), startDate + endDate pickers (optional), Cancel / Create.
Data contract
POST /crm/campaigns (crm.controller.ts:83-87; create-campaign.dto.ts:5-34).
No PATCH/GET-by-id — editing a campaign is (planned).
Behaviors
- Date sanity: endDate ≥ startDate (client validation; server stores as-is today — no server check, flag).
- Success → S10 refresh + snackbar.
Analytics (proposed)
crm.campaign.create.submit|success.
07 — Component Library (CRM Module)
- 1. LeadStatusChip (status badge)
- 2. LeadSourceIcon
- 3. LeadListTile (S1 row)
- 4. FollowUpCard (S2 timeline item)
- 5. ConvertDialog (S4)
- 6. StatusBadge (admission, S5/S6)
- 7. AdmissionListTile (S5 row)
- 8. WorkflowTimeline (S6)
- 9. DocumentCard (S6)
- 10. InterviewCard (S6)
- 11. FunnelStatCard (S5 header)
- 12. CampaignCard (S10 row)
- 13. FilterChipRow (S1/S5)
- 14. FollowUpComposer (S2 sheet)
- Composition rules
Module-specific widgets on top of the shared component library (00-shared/03_Component_Library.md). Shared atoms (
AppButton,AppCard,AppTextField,AppChips,AppBadge,AppAvatar,AppTimeline,AppDialog,AppBottomSheet,AppSkeleton,AppEmptyState,AppFAB,AppDatePicker,AppSegmentedButton) are reused as-is; only CRM-specific compositions are specified here. All colors from 00-shared/02_Design_Tokens.md (proposed until designer sign-off).
1. LeadStatusChip (status badge)
| Prop | Values |
|---|---|
| status | new|contacted|qualified|converted|closed (lead.schema.ts:7-13) |
| Status | Color (semantic) | Icon | Label key |
|---|---|---|---|
| new | color.secondaryContainer | inbox | crm.status.new |
| contacted | color.primaryContainer | call | crm.status.contacted |
| qualified | color.tertiaryContainer | verified | crm.status.qualified |
| converted | success container | check_circle | crm.status.converted |
| closed | surface-variant | block | crm.status.closed |
- Icon + text, never color-only (00-shared/09).
- Disabled variant renders grayed + reduced opacity for non-actionable chips.
2. LeadSourceIcon
| source | icon | label key |
|---|---|---|
| website | language | crm.source.website |
| referral | group | crm.source.referral |
| walk_in | storefront | crm.source.walk_in |
| phone | phone | crm.source.phone |
| campaign | campaign | crm.source.campaign |
| other | more_horiz | crm.source.other |
Sizes: 16 dp inline (list rows), 20 dp in detail header.
3. LeadListTile (S1 row)
Composition: AppAvatar(40, initials) · column(name, subtitle) · trailing
(LeadStatusChip + LeadSourceIcon). Subtitle auto-composed:
email · next follow-up {date} / overdue variant {date} overdue in color.error.
Subtitle logic in a pure function followUpSummary(lead) — unit-tested.
4. FollowUpCard (S2 timeline item)
note (body), meta row: scheduledAt (formatted, DateTime.toDisplayDate),
"completed" check when completedAt set, "by {createdBy}". Data:
lead.schema.ts:24-39. Variants: upcoming (default), due today (primary border),
overdue (error border), completed (muted + check).
5. ConvertDialog (S4)
AppDialog wrapper with pre-condition list + destructive-primary confirm. Props:
lead, canConvert (client rule check per crm.service.ts:118-149), onConvert.
Fixed copy from 06 §S4.
6. StatusBadge (admission, S5/S6)
| status | color | label key |
|---|---|---|
| draft / submitted | secondaryContainer | crm.adm.status.draft|submitted |
| documents_pending | tertiaryContainer | crm.adm.status.documents_pending |
| interview_scheduled | primaryContainer | crm.adm.status.interview_scheduled |
| under_review | secondaryContainer | crm.adm.status.under_review |
| approved | success container | crm.adm.status.approved |
| rejected | error container | crm.adm.status.rejected |
| waitlisted | surface-variant | crm.adm.status.waitlisted |
| converted | success container + check | crm.adm.status.converted |
Statuses: admission.schema.ts:7-17.
7. AdmissionListTile (S5 row)
AppAvatar(40) · name · email · submitted {date} · trailing StatusBadge.
Optional document hint line: "docs: 2/3" (count from documents array;
required-set knowledge (planned)).
8. WorkflowTimeline (S6)
Read-only vertical AppTimeline over workflow[] (admission.schema.ts:52-67):
each node "statusA → statusB", approver name, comment (italic), at timestamp.
Rendered newest-first. Never editable.
9. DocumentCard (S6)
Icon by type (tc → description, marksheet → table_chart, certificate → workspaces,
photo → photo, other → attach_file — admission.schema.ts:27-33), filename,
meta "by {uploadedBy} · {uploadedAt}". Tap → open file (fileId via storage layer;
no download endpoint in this module — (planned)).
10. InterviewCard (S6)
scheduledAt (prominent), mode chip (online/offline), panel chips (names), feedback
body. Empty variant: "No interview scheduled" + Schedule action. Data:
admission.schema.ts:69-81.
11. FunnelStatCard (S5 header)
Title (label key), big number, sub-label. Variants: total, per-status count, and
conversionRate ("N%") from GET /crm/admissions/stats (admission.service.ts:227-237).
Loading → AppSkeleton box; error → "—" with retry icon.
12. CampaignCard (S10 row)
name (title), type icon + label, StatusBadge (campaign enum — campaign.schema.ts:7-12),
date range, metrics line leadsGenerated · converted (campaign.schema.ts:45-52);
metrics absent → "—". Tap → read-only info sheet (no detail endpoint).
13. FilterChipRow (S1/S5)
Horizontal SingleChildScrollView of AppChips (filter chips): first = "All",
selected chip emphasized. Wraps the shared status-filter interaction used by both
lists; emits onChanged(value|null).
14. FollowUpComposer (S2 sheet)
AppTextField multiline (note*), AppDatePicker datetime (scheduledAt*),
optional completedAt toggle (create-follow-up.dto.ts:4-17). Submit Add follow-up.
Composition rules
- Module widgets never fetch data; they render models passed by cubits (00-shared/06 §2).
- All labels via i18n keys (
crm.*namespace) — server text only for business 4xx fallback (00-shared/11 §9). - Goldens required for:
LeadStatusChip(6 statuses × light/dark),StatusBadge(9),WorkflowTimeline,LeadListTile,FunnelStatCard.
08 — Form Specifications (CRM Module)
- 1. Form rules (all forms)
- 2. Lead Form (S3) — create
POST /crm/leads/ editPATCH /crm/leads/:id - 3. Admission Form (S7) — create
POST /crm/admissions - 4. Follow-Up Form (S2 sheet) —
POST /crm/leads/:id/follow-ups - 5. Campaign Form (S11) —
POST /crm/campaigns - 6. Document Upload Form (S6 sheet) —
POST /crm/admissions/:id/documents - 7. Interview Form (S8 sheet) —
POST /crm/admissions/:id/schedule-interview - 8. Decision Form (S9 dialog) —
POST /crm/admissions/:id/decision - 9. Autofill & accessibility
Exact field-by-field form contracts. Validation mirrors server DTOs (class-validator); field sources are cited. Shared form UX: labels linked, per-field errors under fields (00-shared/06 §5), double-submit guard (00-shared/08 §6), keyboard types per field.
1. Form rules (all forms)
- Required = server-required (server is authoritative; client pre-validates).
- MongoId fields render as dropdowns/pickers fed by the owning module
(academics grade/section/academicYear/class; staff for assignment/panel) —
cross-module reads
(planned)where endpoints are missing; fallback: raw-id field hidden in "Advanced". - 400
VALIDATION_ERRORdetails mapfield → messageunder the matching field. - Optimistic updates: never for create/submit; only for safe toggles (00-shared/06 §3.5).
2. Lead Form (S3) — create POST /crm/leads / edit PATCH /crm/leads/:id
| Field | Req | Control | Validation (server) | Default | Source |
|---|---|---|---|---|---|
| firstName | ✅ | text | @IsString | — | create-lead.dto.ts:12-14 |
| middleName | — | text | optional string | — | create-lead.dto.ts:16-19 |
| lastName | ✅ | text | @IsString | — | create-lead.dto.ts:21-23 |
| ✅ | text (.emailAddress, autofill email) | @IsEmail; stored lowercase (lead.schema.ts:52-53) | — | create-lead.dto.ts:25-27 | |
| phone | — | text (.phone, autofill tel) | optional string | — | create-lead.dto.ts:29-32 |
| source | — | dropdown | enum 6 values | website (lead.schema.ts:58) | create-lead.dto.ts:34-37 |
| status | — | dropdown (Advanced) | enum 5 values | new (lead.schema.ts:61) | create-lead.dto.ts:39-42 |
| gradeId | — | dropdown | @IsMongoId | — | create-lead.dto.ts:44-47 |
| sectionId | — | dropdown | @IsMongoId | — | create-lead.dto.ts:49-52 |
| academicYearId | — | dropdown | @IsMongoId | — | create-lead.dto.ts:54-57 |
| classId | — | dropdown | @IsMongoId | — | create-lead.dto.ts:59-62 |
| notes | — | multiline | optional string | — | create-lead.dto.ts:64-67 |
| assignedTo | — | staff dropdown | @IsMongoId | — | create-lead.dto.ts:69-72 |
Edit-only fields: closedReason (string, update-lead.dto.ts:77-80).
Conditional logic
status = closed→ revealclosedReason(client); server auto-stampsclosedAtwhen reason absent (crm.service.ts:94-96).status = converted→ blocked in form; Convert flow only (S4).- Placement warning when editing without grade/year/class: "Set all three to enable
conversion" (rule at
crm.service.ts:145-149). - Submit → 409 duplicate email banner (
crm.service.ts:42-47): "A lead with this email already exists" + "Edit details" / "Go back".
3. Admission Form (S7) — create POST /crm/admissions
| Field | Req | Control | Validation (server) | Source |
|---|---|---|---|---|
| firstName | ✅ | text | @IsString | create-admission.dto.ts:6-8 |
| middleName | — | text | optional | create-admission.dto.ts:10-12 |
| lastName | ✅ | text | @IsString | create-admission.dto.ts:14-16 |
| ✅ | text .emailAddress | @IsEmail; stored lowercase (admission.schema.ts:102-103) | create-admission.dto.ts:18-20 | |
| phone | — | text .phone | optional string | create-admission.dto.ts:22-25 |
| gradeId | — | dropdown | @IsMongoId | create-admission.dto.ts:27-30 |
| academicYearId | — | dropdown | @IsMongoId | create-admission.dto.ts:32-35 |
| classId | — | dropdown | @IsMongoId | create-admission.dto.ts:37-40 |
| notes | — | multiline | optional string | create-admission.dto.ts:42-45 |
- Update form (PATCH
/crm/admissions/:id,update-admission.dto.ts= PartialType) reuses this table; submit blocked client-side when status closed (server 400:admission.service.ts:257-269). - Note: no admission
statusfield — server always createssubmitted(admission.service.ts:43).
4. Follow-Up Form (S2 sheet) — POST /crm/leads/:id/follow-ups
| Field | Req | Control | Validation | Source |
|---|---|---|---|---|
| note | ✅ | multiline | @IsString | create-follow-up.dto.ts:5-7 |
| scheduledAt | ✅ | datetime picker | @IsDateString (ISO) | create-follow-up.dto.ts:9-11 |
| completedAt | — | toggle + datetime | @IsDateString optional | create-follow-up.dto.ts:13-16 |
- Display uses
followUps[](lead.schema.ts:24-39);createdByset server-side from token (crm.service.ts:111).
5. Campaign Form (S11) — POST /crm/campaigns
| Field | Req | Control | Validation | Default | Source |
|---|---|---|---|---|---|
| name | ✅ | text | @IsString | — | create-campaign.dto.ts:5-8 |
| description | — | multiline | optional | — | create-campaign.dto.ts:10-13 |
| type | — | dropdown | enum 5 | email (campaign.schema.ts:30) | create-campaign.dto.ts:15-18 |
| status | — | dropdown | enum 4 | draft (campaign.schema.ts:33) | create-campaign.dto.ts:20-23 |
| startDate | — | date picker | @IsDateString | — | create-campaign.dto.ts:25-28 |
| endDate | — | date picker | @IsDateString | — | create-campaign.dto.ts:30-33 |
- Client: endDate ≥ startDate (no server check today — flagged).
- No edit form (no PATCH endpoint) —
(planned).
6. Document Upload Form (S6 sheet) — POST /crm/admissions/:id/documents
| Field | Req | Control | Validation | Source |
|---|---|---|---|---|
| type | ✅ | dropdown | enum tc/marksheet/certificate/photo/other | create-admission-document.dto.ts:5-8; admission.schema.ts:27-33 |
| fileId | ✅ | file pick → storage upload (storage provider; upload endpoint (planned)) | @IsString | create-admission-document.dto.ts:10-12 |
| filename | — | auto from file | optional string, defaults to fileId server-side (admission.service.ts:107) | create-admission-document.dto.ts:14-17 |
- Server appends doc + auto-transitions
submitted → documents_pending(admission.service.ts:112-114).
7. Interview Form (S8 sheet) — POST /crm/admissions/:id/schedule-interview
| Field | Req | Control | Validation | Default | Source |
|---|---|---|---|---|---|
| scheduledAt | ✅ | datetime picker | @IsDateString | — | schedule-interview.dto.ts:11-14 |
| panel | — | staff multi-pick chips | array of @IsMongoId | — | schedule-interview.dto.ts:16-20 |
| mode | — | segmented online/offline | enum | offline (admission.schema.ts:76-78) | schedule-interview.dto.ts:22-25 |
| feedback | — | multiline | optional string | — | schedule-interview.dto.ts:27-30 |
8. Decision Form (S9 dialog) — POST /crm/admissions/:id/decision
| Field | Req | Control | Validation | Source |
|---|---|---|---|---|
| decision | ✅ | radio/segmented | enum approve/reject/waitlist | admission-decision.dto.ts:4-8,10-13 |
| comment | — | multiline | optional string | admission-decision.dto.ts:15-18 |
- Offered only from decidable statuses (
admission.schema.ts:20-25); server 400 otherwise (admission.service.ts:141-145). - Reject/waitlist warnings per 06 §S9.
9. Autofill & accessibility
- Lead/admission identity fields:
given-name,family-name,email,telautofill hints; labels linked; group headers (Applicant / Contact / Placement / Assignment); first-invalid focus; 409 banner in live region.
09 — User Behaviour (CRM Module)
- 1. Mental model: "a pipeline, not a folder"
- 2. Capture cadence (reception)
- 3. Follow-up discipline
- 4. Status transitions (server-enforced)
- 5. Admission decisions: terminal states
- 6. Error-handling behaviours
- 7. Sorting & scanning expectations
- 8. Confirmation thresholds
- 9. Habit loops
- 10. Anti-patterns to design out
Behavioral expectations and mental models for CRM users; interaction patterns, error handling, and habits. Shared interaction conventions: 00-shared/08_Interaction_&_Motion.md.
1. Mental model: "a pipeline, not a folder"
Counselors think in stages (new → contacted → qualified → converted | closed).
The UI must make the current stage and the next stage obvious on every lead row
(status chip + next follow-up). Status is a statement of progress, not a tag:
changes are deliberate (confirm on convert; reason on close).
2. Capture cadence (reception)
- Fast entry is sacred: 3 required fields, everything else optional
(08 §2); source pre-set by entry context (walk-in desk →
walk_in). - Duplicate-email 409 must be inline and actionable, not a dead end — the
receptionist cannot improvise while a parent waits (
crm.service.ts:42-47). - Habit: after create → immediately offered "Add follow-up" from the detail view (post-create sheet with prefilled date +2 days).
3. Follow-up discipline
- Mental model: every lead has a next action date. Derived client-side from the
earliest uncompleted
followUps[].scheduledAt(lead.schema.ts:28-31) — no dedicated field exists (gap; see 12_API_Mapping.md §7). - Overdue follow-ups surface as error-toned rows in S1; the day's queue = filter by status + client "overdue first" sort (client-side only today).
- Completed follow-ups must be visibly checkable (timeline checkmark) — the distinction scheduled/completed drives trust.
(forward-looking): push reminder on due date (needs device registry).
4. Status transitions (server-enforced)
| From | To | Rule | Source |
|---|---|---|---|
| any | converted | via Convert action only; blocked if already converted or closed | crm.service.ts:120-125 |
| any | converted | grade + academic year + class must be set | crm.service.ts:145-149 |
| any | closed | allowed; closedAt auto-stamp when no reason | crm.service.ts:94-96 |
| closed | anything | not possible (no reopen endpoint) | — |
UI mirrors these rules as disabled buttons + tooltips, but server 400s are mapped to copy (rule drift handling). Users learn: converted and closed are terminal.
5. Admission decisions: terminal states
- Deciding is a commitment: closed statuses (approved/rejected/waitlisted/
converted) are immutable (
admission.service.ts:257-269). Warnings in S9 copy this explicitly; reject says "Application closes permanently." - Decision only from decidable statuses (
admission.schema.ts:20-25) — UI hides the dialog otherwise. - The workflow timeline is the trust artifact: approver + comment per hop
(
admission.schema.ts:52-67) — surfaced read-only on S6.
6. Error-handling behaviours
| Situation | User expectation | Contract |
|---|---|---|
| Duplicate lead email | Clear "already exists" + path forward | 409 (crm.service.ts:42-47) |
| Convert preconditions missing | Know which field to fix | 400 exact message (crm.service.ts:145-149) |
| Convert already-converted/closed | Action disabled with reason | 400 (crm.service.ts:120-125) |
| Edit closed admission | Form locked, banner explains | 400 (admission.service.ts:257-269) |
| Decision from wrong status | Dialog not offered | 400 (admission.service.ts:141-145) |
| Cross-tenant id | "Not found" — never existence leak | 404 (base.repository.ts:20-30) |
| Offline | Banner + cached list + no writes (CRM has no offline write queue; writes require connectivity) | 00-shared/07 §10 |
| Rate limit | Countdown, no auto-retry | 429 (00-shared/07 §4) |
7. Sorting & scanning expectations
- Default order: newest first (leads/campaigns by
createdAt, admissions bysubmittedAt—lead.repository.ts:31,admission.repository.ts:29). - Users scan by status chip + follow-up date; a sort by next-follow-up is the
top
(proposed)enhancement (serversortparam(planned)).
8. Confirmation thresholds
| Action | Confirm? | Rationale |
|---|---|---|
| Create lead/admission/campaign | No | low cost, reversible via edit |
| Update lead fields | No (inline save) | cheap |
| Status → closed | Soft: reason field, no modal | reversible-in-record, but terminal-ish |
| Convert lead/admission | Yes (modal) | creates User + Student records, irreversible (crm.service.ts:118-176) |
| Decision approve/reject/waitlist | Yes (modal) | terminal + immutable (admission.service.ts:136-170) |
| Add follow-up | No | cheap, append-only |
9. Habit loops
- Daily queue: open Leads → status filter → work overdue-first → log follow-up → next contact.
- Weekly funnel check: Admissions → stats header (conversion rate) → status
counts → chase stale applications (reminder worker
(planned)automates). - Campaign review: Campaigns list → compare lead metrics per campaign → adjust
spend (metrics write path
(planned)).
10. Anti-patterns to design out
- Paper-slip duplication (409 catch + fast capture).
- Re-keying identity at conversion (server derives User/Student from lead fields — UI must not re-ask).
- Ghost pipelines: every lead always visible under some status filter; closed leads
are inspectable, never silently deleted (no delete endpoint today; soft-delete
(planned)). - Waiting on pages: all writes show explicit in-flight state; no silent double submits.
10 — Interaction Specification (CRM Module)
- 1. Navigation & routes
- 2. List interactions (S1/S5/S10)
- 3. Lead detail interactions (S2)
- 4. Admission detail interactions (S6)
- 5. Keyboard support
- 6. Motion
- 7. Realtime & freshness
- 8. Optimistic vs pessimistic summary
- 9. Accessibility interactions
Precise interaction rules per screen. Motion timings from 00-shared/08_Interaction_&_Motion.md (
m-fast≤120 ms,m-base≤250 ms,m-slow≤400 ms). All gestures keyboard-accessible.
1. Navigation & routes
| Route | Screen | Guard |
|---|---|---|
/crm | shell + leads tab (S1) | authGuard + permissionGuard('crm.read') |
/crm/leads | S1 | crm.read |
/crm/leads/new | S3 create | crm.lead.manage |
/crm/leads/:id | S2 | crm.read |
/crm/leads/:id/edit | S3 edit (sheet) | crm.lead.manage |
/crm/admissions | S5 | crm.read |
/crm/admissions/new | S7 | crm.lead.manage |
/crm/admissions/:id | S6 | crm.read |
/crm/campaigns | S10 | crm.read |
/crm/campaigns/new | S11 | crm.campaign.manage |
- Guards evaluate client-side for UI; server enforcement
(planned)(permissions exist atpermissions.constants.ts:34-36). - Deep links: push notification →
/crm/leads/:id(forward-looking).
2. List interactions (S1/S5/S10)
- Status filter: chip tap → immediate server request (loading bar atop stale list, never blank page); "All" resets.
- Client-side filters (S1 source / my-leads): filter current loaded page instantly; label "filtered on device" hint when server filter unavailable (04 §6).
- Pull-to-refresh: resets page 1, bypasses cache; S5 also refetches stats.
- Infinite scroll:
loadMorewhenhasNext(mixin 00-shared/06 §3.2); row tap → detail push (phone) / detail pane (tablet). - Row press feedback:
m-fastripple; no long-press actions (no delete/duplicate today).
3. Lead detail interactions (S2)
- Status change: dropdown chip → immediate PATCH, optimistic with rollback
- snackbar on failure (00-shared/06 §3.5); converted option never selectable in the dropdown (Convert flow only).
- Close lead: sets status closed → reveal
closedReasoninline field; save without reason auto-stampsclosedAtserver-side (crm.service.ts:94-96). - Add follow-up: bottom sheet (S3-style), pessimistic submit; success → new timeline entry slides in + snackbar; timeline order: scheduledAt asc.
- Assignment: staff picker sheet; optimistic PATCH (
update-lead.dto.ts:72-75), rollback on error. - Convert: S4 dialog. Pre-check rules client-side; disabled button carries
tooltip with the unmet rule. Confirm → in-dialog progress (never optimistic);
success → dialog swaps to success check
m-base, S2 refreshes. - Edit: sheet with fields of 08 §2; save = PATCH; on 409 → inline banner (email conflict is the only 409 path).
4. Admission detail interactions (S6)
- Documents:
Add documentsheet (08 §6); after success, status badge may flipsubmitted → documents_pending(admission.service.ts:112-114) — refresh detail; snackbar notes the transition. - Interview:
Schedulesheet (08 §7); re-schedule allowed while open (overwrites,admission.service.ts:124-129); after save, status →interview_scheduledwhen not already (admission.service.ts:130-132). - Decision: S9 dialog; selection required; confirm disabled until choice; waitlist/reject warnings per choice; success → status + workflow timeline update.
- Convert (approved only): S4-style dialog; success →
converted+ conversion card (admission.service.ts:212-217). - Locked admission: action bar replaced by read-only banner; PATCH attempts
blocked (client + server 400
admission.service.ts:257-269).
5. Keyboard support
- S1: status chips → left/right arrows + Enter; rows → up/down + Enter.
- Forms: Tab order = field declaration order (08); Enter on last field submits; Esc closes sheets/dialogs; focus returns to invoker.
- Dialogs (S4/S9): Esc = cancel; Enter = confirm (when enabled); focus lands on primary action after warnings read.
6. Motion
| Transition | Timing | Notes |
|---|---|---|
| Row tap → detail | m-base push (phone) / fade pane (tablet) | |
| Sheet/dialog | m-base slide/fade | |
| Status chip change | m-fast color/icon swap | no bounce |
| Timeline entry add | slide-in m-base + 40 ms stagger | |
| Error shake | m-fast horizontal 2×6 px | forms only |
| Success check (S4) | m-base scale-in check | |
| Skeleton → content | fade m-base | no flash of empty |
Reduced-motion: disable shake/stagger (.disableAnimations).
7. Realtime & freshness
- No WS topics for CRM today (00-shared/07 §8) → no live updates; refetch on
app foreground + pull-to-refresh.
(forward-looking): subscribecrm.lead.updated/crm.admission.updatedtopics when gateway adds them. - Detail views: no client cache (always fetch, 00-shared/06 §3.3).
8. Optimistic vs pessimistic summary
| Mutation | Policy | Reason |
|---|---|---|
| PATCH lead fields/status/assignedTo | optimistic + rollback | cheap, reversible (00-shared/06 §3.5) |
| Add follow-up | pessimistic | append + server createdBy stamp |
| Create lead/admission/campaign | pessimistic | 409/400 paths need server truth |
| Convert lead/admission | pessimistic | side effects (User+Student) |
| Admission decision | pessimistic | terminal side effects |
| Document/interview | pessimistic | workflow transitions |
9. Accessibility interactions
- Status/color changes always accompanied by icon + text (00-shared/09).
- Convert/decision warnings read aloud before confirm; focus containment in dialogs; sheet scrims dismiss with confirmation only when form is dirty.
11 — Design System Mapping (CRM Module)
- 1. Status color semantics (single source)
- 2. Source icons & typography
- 3. Shared components → module mapping
- 4. Spacing & density
- 5. Empty & error states (copy)
- 6. Accessibility tokens
- 7. Motion tokens (per 00-shared/08)
- 8. Copy & i18n
Maps CRM UI to the shared design system (00-shared/04_Design_System_Mapping.md, tokens in 00-shared/02_Design_Tokens.md — proposed defaults until designer sign-off). Defines status → semantic-color rules, type scale usage, and shared component substitutions.
1. Status color semantics (single source)
Lead statuses (lead.schema.ts:7-13):
| Status | Role color | Container | Token intent |
|---|---|---|---|
| new | onSecondaryContainer | secondaryContainer | neutral/awaiting |
| contacted | onPrimaryContainer | primaryContainer | in-progress |
| qualified | onTertiaryContainer | tertiaryContainer | warm/positive |
| converted | onSuccessContainer | successContainer | terminal-success |
| closed | onSurfaceVariant | surfaceVariant | terminal-muted |
Admission statuses (admission.schema.ts:7-17): open states use neutral
secondary/primary/tertiary containers; approved/converted → success;
rejected → error container; waitlisted → surfaceVariant.
Campaign statuses (campaign.schema.ts:7-12): active → success-tinted border;
draft/paused/completed → surface variants with icon.
Rule: never color-only — every badge has icon + label (00-shared/09:76).
2. Source icons & typography
- Source icons per 07 §2 (material symbols, 20 dp default).
- Typography: list rows →
titleMedium(name) /bodySmall(meta); detail headers →headlineSmall; stat cards →headlineMedium(number) /labelLarge(caption); timeline →bodyMedium+labelSmalltimestamps.
3. Shared components → module mapping
| Shared (00-shared/03) | Module usage |
|---|---|
AppCard | pipeline/placement/documents/interview/notes cards (S2/S6) |
AppListTile | leads/admissions rows (wrapped in module tiles 07 §3,7) |
AppChips | filter rows (07 §13), panel/mode chips, source chips |
AppBadge | status badges (07 §1,6) |
AppAvatar | identity (40/56 dp), initials from name |
AppTimeline | follow-up timeline (S2), workflow timeline (S6, read-only) |
AppDialog | Convert (S4), Decision (S9) |
AppBottomSheet | forms (S3, follow-up, document, interview, campaign) |
AppDatePicker | scheduledAt, dates (datetime variant for follow-up/interview) |
AppSegmentedButton | decision choice, interview mode |
AppSkeleton / AppEmptyState / AppErrorState / AppOfflineBanner | all list/detail states |
AppFAB | create actions (gated by permission) |
AppTextField | all form fields |
4. Spacing & density
- List rows: 72 dp height (avatar 40 + two lines) — dense enough for a working queue; detail cards 16 dp padding.
- Filter rows: 8 dp above list; chips 8 dp gap.
- Bottom action bars: 8 dp safe-area padding; primary action right-aligned.
- Tablet: two-pane master-detail with 320 dp min detail width; sheets ≤ 560 dp.
5. Empty & error states (copy)
| State | Illustration | Copy |
|---|---|---|
| No leads | inbox | "No leads yet — create your first lead" |
| No leads in filter | filter_alt_off | "No leads in this status" + Clear filter |
| No admissions | description | "No applications yet" |
| No campaigns | campaign | "No campaigns yet" |
| Lead 404 | search_off | "Lead not found" |
| Offline | cloud_off | standard AppOfflineBanner + cached list |
| Permission | lock | "Ask your admin for CRM access" (when crm.read missing) |
6. Accessibility tokens
- Overdue text:
color.errorat ≥ 4.5:1 on card surface; paired with alert icon. - Focus ring: 2 dp outline in
color.primary; minimum touch targets 48 dp (chips 40 dp acceptable with padding). - Dark mode: all container pairs defined in tokens; goldens cover light/dark × 3 sizes for badges/timeline/stat cards (07 §Composition rules).
7. Motion tokens (per 00-shared/08)
Standard curves/easings; durations per 10 §6. Status chip transitions use the
m-fast emphasis variant; timeline stagger is the only module-specific choreography.
8. Copy & i18n
All strings keyed under crm.* (see 07 §Composition rules, 08 §1);
server business-4xx messages fall back via error-code → key mapping
(00-shared/11 §9). Locale: en first, org-locales on demand.
12 — API Mapping (CRM Module)
- 1. Endpoint table
- 2. Response shapes
- 3. Sort & filter contract (exact)
- 4. Domain rules → status codes
- 5. Events emitted (consumers
(planned)) - 6. Data model (wire field names)
- 7. API gaps (
(planned)/ gaps to raise)
Exact endpoint ↔ screen mapping. Base URL
https://api.<domain>/api/v1, bearer auth, envelope shapes per 00-shared/07_API_Conventions.md. All routes under@Controller('crm')(crm.controller.ts:30), JWT-guarded (crm.controller.ts:29); RBAC permission decorators are not applied yet (perms exist atpermissions.constants.ts:34-36; enforcement(planned), IMPLEMENTATION_PLAN.md §5.1).
1. Endpoint table
Leads
| # | Method | Path | Screen | Params/Body | Perm | Source |
|---|---|---|---|---|---|---|
| 1 | GET | /crm/leads | S1 | page (1), limit (20), status (enum) | crm.read | crm.controller.ts:37-45 |
| 2 | POST | /crm/leads | S3 | CreateLeadDto | crm.lead.manage | crm.controller.ts:47-51 |
| 3 | GET | /crm/leads/:id | S2 | — | crm.read | crm.controller.ts:53-57 |
| 4 | PATCH | /crm/leads/:id | S3/S2 | UpdateLeadDto (partial) | crm.lead.manage | crm.controller.ts:59-63 |
| 5 | POST | /crm/leads/:id/follow-ups | S2 | CreateFollowUpDto | crm.lead.manage | crm.controller.ts:65-69 |
| 6 | POST | /crm/leads/:id/convert | S4 | — | crm.lead.manage | crm.controller.ts:71-75 |
Campaigns
| # | Method | Path | Screen | Params/Body | Perm | Source |
|---|---|---|---|---|---|---|
| 7 | GET | /crm/campaigns | S10 | page, limit | crm.read | crm.controller.ts:77-81 |
| 8 | POST | /crm/campaigns | S11 | CreateCampaignDto | crm.campaign.manage | crm.controller.ts:83-87 |
Admissions
| # | Method | Path | Screen | Params/Body | Perm | Source |
|---|---|---|---|---|---|---|
| 9 | POST | /crm/admissions | S7 | CreateAdmissionDto | crm.lead.manage | crm.controller.ts:91-95 |
| 10 | GET | /crm/admissions/stats | S5 | — | crm.read | crm.controller.ts:97-101 |
| 11 | GET | /crm/admissions | S5 | page, limit, status | crm.read | crm.controller.ts:103-111 |
| 12 | GET | /crm/admissions/:id | S6 | — | crm.read | crm.controller.ts:113-117 |
| 13 | PATCH | /crm/admissions/:id | S6 | UpdateAdmissionDto (partial) | crm.lead.manage | crm.controller.ts:119-123 |
| 14 | POST | /crm/admissions/:id/documents | S6 | CreateAdmissionDocumentDto | crm.lead.manage | crm.controller.ts:125-132 |
| 15 | POST | /crm/admissions/:id/schedule-interview | S6/S8 | ScheduleInterviewDto | crm.lead.manage | crm.controller.ts:134-141 |
| 16 | POST | /crm/admissions/:id/decision | S6/S9 | AdmissionDecisionDto | crm.lead.manage | crm.controller.ts:143-147 |
| 17 | POST | /crm/admissions/:id/convert | S6 | — | crm.lead.manage | crm.controller.ts:149-153 |
2. Response shapes
- List endpoints (1, 7, 11): envelope
data: Lead[]/Campaign[]/Admission[]+meta: {page, limit, totalItems, totalPages, hasNext, hasPrevious}viabuildPaginationMeta(crm.service.ts:75,79,191,193;admission.service.ts:68-72). - Detail (3, 12) & mutations:
data= document, nometa. - Stats (10):
data: {new?: 0, submitted?: n, ..., total, conversionRate: x.x}(admission.service.ts:227-237); counts keyed byAdmissionStatusenum.
3. Sort & filter contract (exact)
| List | Server sort (fixed) | Filters supported | Source |
|---|---|---|---|
| Leads | createdAt: -1 | status only | lead.repository.ts:24-36 (sort at :31) |
| Admissions | submittedAt: -1 | status only | admission.repository.ts:25-35 (sort at :29) |
| Campaigns | createdAt: -1 | none | campaign.repository.ts:21-31 (sort at :25) |
No q, no sort, no assignedTo/source query params anywhere in CRM —
client-side filtering per 04 §6.
4. Domain rules → status codes
| Rule | Code | Message (verbatim) | Source |
|---|---|---|---|
| Duplicate lead email | 409 | Lead with email "{email}" already exists. | crm.service.ts:42-47 |
| Lead not found | 404 | Lead not found. | crm.service.ts:82-86,98 |
| Convert already-converted | 400 | Lead is already converted. | crm.service.ts:120-122 |
| Convert closed lead | 400 | Cannot convert a closed lead. | crm.service.ts:123-125 |
| Convert missing placement | 400 | Lead must have grade, academic year, and class assigned for conversion. | crm.service.ts:145-149 |
| Admission not found | 404 | Admission not found. | admission.service.ts:75-79 |
| Decide from wrong status | 400 | Admission in status "{status}" cannot be decided. | admission.service.ts:141-145 |
| Edit closed admission | 400 | Admission in status "{status}" is not editable. | admission.service.ts:257-269 |
| Convert non-approved | 400 | Only approved admissions can be converted. | admission.service.ts:174-178 |
| Convert missing placement | 400 | Admission must have grade, academic year, and class for conversion. | admission.service.ts:179-183 |
Cross-tenant access → 404 via scopedFilter (base.repository.ts:20-30).
Validation 400s carry details[{field, message}] (00-shared/07 §3).
5. Events emitted (consumers (planned))
| Event | Emitted at | Payload keys | Source |
|---|---|---|---|
LeadCreated | POST leads | leadId, firstName, lastName, email, source | crm.service.ts:50-63 |
LeadConverted | POST leads/:id/convert | leadId, studentId, userId | crm.service.ts:166-173 |
AdmissionSubmitted | POST admissions | admissionId, email | admission.service.ts:54-57 |
AdmissionApproved | decision approve | admissionId, email | admission.service.ts:158-162 |
AdmissionRejected | decision reject | admissionId, email | admission.service.ts:163-167 |
AdmissionConverted | POST admissions/:id/convert | admissionId, studentId | admission.service.ts:219-223 |
Waitlist decision emits no event today.
6. Data model (wire field names)
- Lead:
firstName, middleName?, lastName, email, phone?, source, status, gradeId?, sectionId?, academicYearId?, classId?, notes?, assignedTo?, followUps?: [{note, scheduledAt, completedAt?, createdBy?, createdAt}], convertedAt?, convertedToStudentId?, closedAt?, closedReason?, metadata?+ BaseSchema (tenantId, createdBy, updatedBy, isDeleted, deletedAt, deletedBy, version, createdAt, updatedAt) —lead.schema.ts:41-99; BaseSchema per 00-shared/01 §10. - Admission:
firstName, middleName?, lastName, email, phone?, gradeId?, academicYearId?, classId?, status, documents?: [{type, fileId, filename, uploadedBy?, uploadedAt}], workflow?: [{from, to, approver?, comment?, at}], interview?: {scheduledAt, panel?, mode?, feedback?}, conversion?: {studentId, convertedAt}, notes?, submittedAt?, decidedAt?—admission.schema.ts:91-144. - Campaign:
name, description?, type, status, startDate?, endDate?, targetAudience?, metrics?: {leadsGenerated?, converted?, sent?, opened?, clicked?}, metadata?—campaign.schema.ts:23-56.
7. API gaps ((planned) / gaps to raise)
| Gap | Impact | Notes |
|---|---|---|
No lead search/q | can't find by name | global search incl. leads (planned) (IMPLEMENTATION_PLAN.md §3) |
No assignedTo/source list filters | "my leads" is client-side | server filters (planned) |
No nextContactDate field | follow-up due derived from followUps[] client-side | add field or dedicated endpoint (proposed) |
| No lead/admission DELETE | no hard/soft delete in UI | BaseRepository.softDelete exists (base.repository.ts:68-74); endpoint (planned) |
| No campaign detail/PATCH | campaigns read-only after create | (planned) |
| No campaign metrics write | metrics schema-only | (planned) |
| No document download/upload endpoints | fileId opaque | storage wiring (planned) |
No admission assignedTo | no owner for applications | (proposed) |
| Admission workers (reminder/expiry) | stale apps not auto-handled | queues named in 00-shared/01 §6; findStale ready (admission.repository.ts:53-62) |
| RBAC decorators | all routes = any authed user | enforcement (planned) (IMPLEMENTATION_PLAN.md §5.1) |
| Lead → batch conversion (coaching) | school-only flow today | (planned) (IMPLEMENTATION_PLAN.md §6.6) |
13 — State Management (CRM Module)
- 1. Cubits & responsibilities
- 2. LeadsCubit (S1) — list + derived follow-up
- 3. LeadDetailCubit (S2)
- 4. LeadFormCubit (S3)
- 5. FollowUpCubit (S2 sheet)
- 6. ConvertCubit (S4)
- 7. AdmissionsCubit (S5) — list + stats
- 8. AdmissionDetailCubit (S6)
- 9. InterviewCubit (S8) / DecisionCubit (S9) / AdmissionFormCubit (S7)
- 10. CampaignsCubit (S10) / CampaignFormCubit (S11)
- 11. State diagram
- 12. Cross-cutting
Per-screen state on top of 00-shared/06_State_Management.md (Bloc/Cubit, repository layer, SWR cache, optimistic-update rules, pagination mixin). CRM's defining problems: two status-driven lists with server pagination, a derived next-follow-up, and terminal, side-effect-heavy mutations (convert, decide) that are never optimistic.
1. Cubits & responsibilities
| Cubit | Screen(s) | State |
|---|---|---|
LeadsCubit | S1 | page + status filter + client-side source/mine filters + rows |
LeadDetailCubit | S2 | full lead + timeline + mutation flags |
LeadFormCubit | S3 | create/edit form, 409 handling |
FollowUpCubit | S2 sheet | follow-up form lifecycle |
ConvertCubit | S4 | pre-check + in-flight + result |
AdmissionsCubit | S5 | page + status filter + stats tiles |
AdmissionDetailCubit | S6 | admission + workflow + mutation flags |
AdmissionFormCubit | S7 | create form |
InterviewCubit | S8 | schedule form lifecycle |
DecisionCubit | S9 | decision form lifecycle |
CampaignsCubit | S10 | paged list |
CampaignFormCubit | S11 | create form |
All implement LoadState {Initial, Loading, Success, Error} (00-shared/06 §3.1);
lists implement PaginatedListMixin (contract mirrors API: page/limit/meta).
2. LeadsCubit (S1) — list + derived follow-up
state: {
loadState, statusFilter: LeadStatus?, sourceFilter: LeadSource?,
mineOnly: bool, items: [Lead], meta, page
}
events: Load, Refresh, ChangeStatus(status?), ChangeSource(source?),
ToggleMine, LoadMore, Retry
- Server request:
GET /crm/leads?page&limit&status(crm.controller.ts:37-45); server sort fixedcreatedAt: -1(lead.repository.ts:31). - Client-side filters (source, mineOnly) applied over loaded pages only —
banner hint "filtered on device" (
10 §2); server filters(planned). - Derived per row:
followUpSummary(lead)→ next due = minscheduledAtoverfollowUpswithoutcompletedAt(lead.schema.ts:28-31); overdue flag. Pure function, unit-tested; cache keycrm.leads:{status}(SWR, 5 min TTL — volatile;RefreshIndicatorbypasses). - Realtime: none — no WS topic; refetch on foreground resume
(
(forward-looking)topiccrm.lead.updated).
3. LeadDetailCubit (S2)
state: {loadState, lead, saving: {fields|assignment}, converting: bool,
lastError: ApiException?}
events: Load, Refresh, UpdateFields(patch), Assign(staffId),
CloseWith(reason), FollowUpAdded(fu)
- Load:
GET /crm/leads/:id(crm.controller.ts:53-57) — no client cache. UpdateFields→ PATCH (crm.controller.ts:59-63) — optimistic with rollback (00-shared/06 §3.5); rollback restores previous lead; snackbar on failure.- Status
closedwithout reason → localclosedReasonprompt; server stampsclosedAt(crm.service.ts:94-96) — reconcile from response. FollowUpAdded→ refresh detail (pessimistic; response authoritative, carriescreatedBy).- On 401 →
sessionExpiredflow (00-shared/06 §3.6).
4. LeadFormCubit (S3)
state: {loadState, mode: create|edit, fields, fieldErrors, conflict:
{email, existingLeadId?}, saving}
- Create →
POST /crm/leads; Edit →PATCH /crm/leads/:id. - 409 duplicate (
crm.service.ts:42-47): stateconflict→ inline banner; action "View existing" → lookup(planned)(no email-search endpoint; today: edit fields). Double-submit guarded. - 400
VALIDATION_ERROR→ mapdetails[].field → fieldErrors.
5. FollowUpCubit (S2 sheet)
- POST
crm.controller.ts:65-69; pessimistic; success → emitFollowUpAdded→ detail refetch; validation per08 §4.
6. ConvertCubit (S4)
state: {preCheck: {pass, missing: [String]}, inFlight, result?, error}
- Pre-check (client, mirrors
crm.service.ts:118-149): status ∉ {converted, closed}; placement = gradeId+academicYearId+classId set. - Execute:
POST /crm/leads/:id/convert(crm.controller.ts:71-75) — never optimistic (User+Student side effects). 400 → map exact message to i18n (12 §4); success → refresh detail + snackbar.
7. AdmissionsCubit (S5) — list + stats
state: {loadState, statusFilter, items, meta, stats: {counts, total,
conversionRate}?, statsState}
- List:
GET /crm/admissions?page&limit&status(crm.controller.ts:103-111); sortsubmittedAt: -1(admission.repository.ts:29). - Stats:
GET /crm/admissions/stats(crm.controller.ts:97-101) — independentstatsState(failure → tiles "—", list still usable). Refreshrefetches both in parallel.
8. AdmissionDetailCubit (S6)
state: {loadState, admission, saving, deciding, scheduling, uploading,
editable: bool (decidable/open)}
editable= status decidable (admission.schema.ts:20-25) for decisions; PATCH allowed while not closed (admission.service.ts:257-269).- Mutations: documents POST (
crm.controller.ts:125-132), interview POST (:134-141), decision POST (:143-147), convert POST (:149-153) — all pessimistic; each refreshes the admission (workflow history authoritative). - Status transitions surfaced as snackbar:
submitted → documents_pending(admission.service.ts:112-114),→ interview_scheduled(:130-132).
9. InterviewCubit (S8) / DecisionCubit (S9) / AdmissionFormCubit (S7)
- Single-purpose form lifecycle cubits; validation per
08 §6-8; pessimistic submit; success → pop sheet/dialog + detail/list refresh. - Decision: explicit choice required; comment optional
(
admission-decision.dto.ts:10-18).
10. CampaignsCubit (S10) / CampaignFormCubit (S11)
- List:
GET /crm/campaigns(crm.controller.ts:77-81); no detail endpoint — rows are read-only. Create:POST /crm/campaigns(crm.controller.ts:83-87). - Metrics (
campaign.schema.ts:45-52) rendered from payload; absent → "—".
11. State diagram
flowchart TD
A[CRM Shell] -->|crm.read| B[LeadsCubit]
A -->|crm.read| C[AdmissionsCubit]
A -->|crm.read| D[CampaignsCubit]
B --> E[LeadDetailCubit]
E --> F[LeadFormCubit]
E --> G[FollowUpCubit]
E --> H[ConvertCubit]
H -->|success| E
C --> I[AdmissionDetailCubit]
I --> J[AdmissionFormCubit]
I --> K[InterviewCubit]
I --> L[DecisionCubit]
K -->|success| I
L -->|success| I
I -.->|convert| M[ConvertCubit reuse]
M -->|success| I
D --> N[CampaignFormCubit]
classDef flow fill:#e8f0fe,stroke:#3949ab;
class B,C,D,E,I,H flow;
12. Cross-cutting
- Auth/session: all cubits react to
sessionExpired→ redirect login, restore route on re-login (00-shared/06 §3.6). - Permissions:
crm.readgates tabs/routes;crm.lead.managegates all write actions;crm.campaign.managegates S11 — evaluated at route build + guard (00-shared/05 §9); server authoritative (enforcement(planned),permissions.constants.ts:34-36). - Offline: reads = SWR cache + banner; writes are never queued in CRM (no offline write queue — explicit decision; forms require connectivity).
- Testing hooks: pure-Dart cubits with mocked repositories; widget tests for
three-state machine, optimistic rollback (status/assignment), 409 banner,
pre-check gating (
00-shared/06 §6).
14 — QA Checklist (CRM Module)
- 1. Duplicate lead detection
- 2. Status validation & transitions
- 3. Assignment & ownership
- 4. Soft-delete semantics
- 5. Follow-up integrity
- 6. Admissions documents / interview
- 7. List/pagination/sort contracts
- 8. Events & workers
- 9. Permissions & RBAC
- 10. Performance & offline
- 11. Accessibility (module additions to 00-shared/09 §12)
- 12. Localization & formatting
- 13. Performance budgets (module)
- 14. Release gates (module additions)
Module-specific checklist layered on 00-shared/10_QA_Baseline.md + 00-shared/09 accessibility baseline. Every item testable; backend rules cite source lines.
1. Duplicate lead detection
-
Same email twice → second POST returns 409
Lead with email "x" already exists.(crm.service.ts:42-47); no second lead doc. -
Email comparison is case-insensitive:
A@X.comvsa@x.comcollide (lead.schema.ts:52-53lowercase;lead.repository.ts:19lowercases query). -
Duplicate check is tenant-scoped (
scopedFilter,base.repository.ts:20-30): same email in two tenants → both allowed. - Create UI: 409 shows inline banner, form keeps values, no data loss, no duplicate double-submit (00-shared/08 §6).
-
PATCH changing email to an existing one → 409? — known gap: update path
does not check duplicates (
crm.service.ts:88-100only merges fields); flag + track.
2. Status validation & transitions
-
Status accepts only
new|contacted|qualified|converted|closed(lead.schema.ts:7-13); invalid value → 400VALIDATION_ERROR(@IsEnum,update-lead.dto.ts:42-45). -
Setting
status: closedwithoutclosedReason→closedAtauto-stamped (crm.service.ts:94-96); with reason →closedAtstill stamped, reason stored. -
Convert on
convertedlead → 400Lead is already converted.(crm.service.ts:120-122); onclosed→ 400Cannot convert a closed lead.(:123-125). -
Convert without gradeId+academicYearId+classId → 400 with exact placement
message (
crm.service.ts:145-149). -
Successful convert: status
converted,convertedAt,convertedToStudentIdset (crm.service.ts:161-164); one User + one Student doc created; no duplicate when user email already exists (reuse path:132-143). -
Admission decision only from decidable statuses
(
ADMISSION_DECIDABLE_STATUSES,admission.schema.ts:20-25); others → 400 (admission.service.ts:141-145). -
Decision maps: approve→
approved, reject→rejected, waitlist→waitlisted;decidedAtset; workflow stage appended with approver + comment (admission.service.ts:147-155,239-255). -
Convert admission only from
approved→ else 400 (admission.service.ts:174-178).
3. Assignment & ownership
-
PATCH
assignedToaccepts MongoId (update-lead.dto.ts:72-75); invalid → 400. -
List filter by
assignedTodoes not exist server-side (onlystatus—crm.controller.ts:37-45): "my leads" is client-side; verify no misleading server claims (flag as gap). -
assignedToisStaffref (lead.schema.ts:79-80); display names resolve from staff module (cross-module read(planned)). -
Follow-up
createdByis stamped from JWT user, never from body (crm.service.ts:111) — client cannot spoof.
4. Soft-delete semantics
- No delete endpoints exist in CRM — UI must not offer delete; verify 405 on any client-invented DELETE.
-
BaseRepository.softDelete(base.repository.ts:68-74) exists but is unreachable from CRM controllers — when(planned)delete lands: deleted leads excluded from lists (isDeleted: falsein scoped filter,base.repository.ts:20-30), version incremented. - Cross-tenant read of another tenant's lead/admission id → 404, not 403 or 500 (no existence leak).
-
Closed leads remain visible under status filter (never auto-purged);
metadatasurvives all PATCHes.
5. Follow-up integrity
-
POST follow-up with invalid
scheduledAt→ 400 (@IsDateString,create-follow-up.dto.ts:9-11). -
Follow-ups append (never replace): two POSTs → array length 2
(
crm.service.ts:114). -
completedAtoptional; uncompleted follow-ups drive the derived "next follow-up due" (client logic — unit testfollowUpSummary). -
Timestamps round-trip ISO-8601 in tenant timezone (00-shared/10 §3 pattern);
overdue detection uses tenant-local
now.
6. Admissions documents / interview
-
Document type enum:
tc|marksheet|certificate|photo|other(admission.schema.ts:27-33); invalid → 400. -
filenamedefaults tofileIdserver-side when absent (admission.service.ts:107). -
First document on
submittedadmission → status auto-advances todocuments_pending(admission.service.ts:112-114); on other open statuses → status unchanged. -
Scheduling interview sets
interview{...}+ statusinterview_scheduledwhen different (admission.service.ts:124-132); re-schedule overwrites cleanly. -
Interview
moderestrictedonline|offline(schedule-interview.dto.ts:22-25; defaultoffline). -
Document/interview/edit on closed admission → 400
not editable(admission.service.ts:257-269); UI locks simultaneously.
7. List/pagination/sort contracts
-
Leads default sort
createdAt: -1(lead.repository.ts:31); admissionssubmittedAt: -1(admission.repository.ts:29); campaignscreatedAt: -1(campaign.repository.ts:25) — verify row order client-side. -
limitbounds (default 20; 1–100 per 00-shared/07 §5):limit=200behavior verified (server default cap). -
meta={page, limit, totalItems, totalPages, hasNext, hasPrevious}(buildPaginationMeta); infinite scroll stops athasNext=false. -
Invalid
statusquery value → 400 (typedLeadStatus/AdmissionStatusparams,crm.controller.ts:42,108). -
Stats endpoint sums match list counts per status (
admission.service.ts:227-237);conversionRate= converted/total × 100, 1 decimal,0when empty.
8. Events & workers
-
Create lead emits
LeadCreatedwith leadId/name/email/source (crm.service.ts:50-63); convert emitsLeadConvertedwith leadId/studentId/userId (:166-173). -
Admission emits
AdmissionSubmitted|Approved|Rejected|Convertedat the right transitions (admission.service.ts:54-57,158-168,219-223); waitlist emits none (documented behavior). -
Reminder/expiry workers
(planned)(IMPLEMENTATION_PLAN.md §1.3): when landed, verifyfindStaleglobal scan (admission.repository.ts:53-62) excludes soft-deleted + respects cutoff; no cross-tenant action without tenantId in event.
9. Permissions & RBAC
-
Permission strings
crm.read,crm.lead.manage,crm.campaign.manage(permissions.constants.ts:34-36) referenced by UI gates. -
Known gap: endpoints are
JwtAuthGuard-only (crm.controller.ts:29) — any authenticated user can write today; assert current behavior and track RBAC enforcement(planned)(IMPLEMENTATION_PLAN.md §5.1). -
Client hides write actions without
crm.lead.manage/crm.campaign.manage(defense-in-depth until server enforces). -
admissions/statscorrectness: no per-status leak beyond tenant scope.
10. Performance & offline
-
Leads list (20 rows) first frame from cache < 300 ms;
ListView.buildermandatory (00-shared/11 §13). - S5 stats + list parallel fetch: stats failure never blocks list.
-
Offline: cached list +
AppOfflineBanner; all CRM writes blocked offline with clear message (no offline queue by design, 13 §12). - Large follow-up arrays: timeline uses builder, no layout jank.
- Detail fetch per navigation (no stale cache) — verify no blank flash (skeleton shown).
11. Accessibility (module additions to 00-shared/09 §12)
- Status badges announce icon + label (no color-only: converted/closed, approved/rejected) (00-shared/09:76).
- Timeline (follow-ups, workflow) fully readable via TalkBack/VoiceOver, per-stage semantics + dates.
- Convert/decision dialogs: warnings read before focus lands on confirm.
- Text scale 2×: filter chip rows scroll, no clipped status text.
- Keyboard-only: chip arrows + Enter; Esc closes sheets/dialogs; focus returns to invoker.
12. Localization & formatting
-
All
crm.*i18n keys present in en + org locales; server business-4xx text mapped via error-code→key fallback (00-shared/11 §9). -
Dates via
Intltenant locale; wire formatYYYY-MM-DD/ISO-8601 independent of display; RTL-safe rows.
13. Performance budgets (module)
| Check | Budget |
|---|---|
| List first frame (cached) | < 300 ms |
| Status filter round-trip | < 500 ms p95 with skeleton kept |
| Detail open | < 500 ms to content (network) |
| Convert flow (server chain) | < 3 s with in-dialog progress |
| Admissions stats tile | < 1 s; failure → "—" |
14. Release gates (module additions)
-
e2e parity: leads CRUD + 409 duplicate + convert 400s + admission decision
400s pass against live backend (pattern:
p1-operations.e2e-spec.ts). -
Goldens:
LeadStatusChip×6,StatusBadge×9,WorkflowTimeline,LeadListTile,FunnelStatCard— light/dark × 3 sizes. - No pending P0: duplicate-email UX, terminal-state locks, cross-tenant 404.
15 — Flutter Implementation Guide (CRM Module)
- 1. Module structure
- 2. Enums & models (mirror server exactly)
- 3. Repositories (via
AppDio) - 4. Domain logic (pure, unit-tested)
- 5. Cubits
- 6. Routing
- 7. Widgets
- 8. i18n
- 9. Offline policy
- 10. Testing plan
- 11. Build order
How to build the CRM feature slice in the Flutter client. Extends 00-shared/11_Flutter_App_Architecture.md (folder structure, DI, dio, router, testing). Forward-looking spec — no client repo exists yet.
1. Module structure
lib/features/crm/
├── data/
│ ├── dto/
│ │ ├── lead_dto.dart # LeadDto.fromJson → Lead
│ │ ├── admission_dto.dart # AdmissionDto + nested (workflow, documents, interview, conversion)
│ │ └── campaign_dto.dart
│ ├── models/
│ │ ├── lead.dart # Lead, LeadStatus, LeadSource, FollowUp
│ │ └── admission.dart # Admission, AdmissionStatus, AdmissionDocumentType, WorkflowStage
│ ├── repositories/
│ │ ├── lead_repository.dart
│ │ ├── admission_repository.dart
│ │ └── campaign_repository.dart
│ └── data_sources/ (none extra; dio via AppDio)
├── domain/
│ ├── follow_up_summary.dart # pure: next due / overdue from FollowUp[]
│ └── convert_rules.dart # pure: canConvert(lead) mirroring crm.service.ts:118-149
└── presentation/
├── cubit/
│ ├── leads_cubit.dart
│ ├── lead_detail_cubit.dart
│ ├── lead_form_cubit.dart
│ ├── follow_up_cubit.dart
│ ├── convert_cubit.dart
│ ├── admissions_cubit.dart
│ ├── admission_detail_cubit.dart
│ ├── admission_form_cubit.dart
│ ├── interview_cubit.dart
│ ├── decision_cubit.dart
│ ├── campaigns_cubit.dart
│ └── campaign_form_cubit.dart
├── pages/lead_list_page.dart, lead_detail_page.dart, lead_form_sheet.dart,
│ admissions_page.dart, admission_detail_page.dart,
│ admission_form_page.dart, campaigns_page.dart, campaign_form_sheet.dart
└── widgets/lead_status_chip.dart, lead_source_icon.dart, status_badge.dart,
workflow_timeline.dart, document_card.dart, interview_card.dart,
funnel_stat_card.dart, convert_dialog.dart, decision_dialog.dart,
follow_up_composer.dart, filter_chip_row.dart
2. Enums & models (mirror server exactly)
LeadStatus:new, contacted, qualified, converted, closed(lead.schema.ts:7-13);LeadSource:website, referral, walk_in, phone, campaign, other(lead.schema.ts:15-22).FollowUp:note, scheduledAt(DateTime), completedAt?, createdBy?, createdAt.AdmissionStatus: 9 values (admission.schema.ts:7-17);AdmissionDocumentType:tc, marksheet, certificate, photo, other(:27-33).WorkflowStage:from, to, approver?, comment?, at.CampaignStatus/CampaignType:campaign.schema.ts:7-20.DateTimefrom ISO strings; refs stayStringObjectIds (display resolved via other modules' repositories).
3. Repositories (via AppDio)
| Repo | Methods → endpoint |
|---|---|
LeadRepository | list({page, limit, status}) → GET /crm/leads; get(id) → GET /crm/leads/:id; create(dto) → POST; update(id, patch) → PATCH; addFollowUp(id, dto) → POST /crm/leads/:id/follow-ups; convert(id) → POST /crm/leads/:id/convert |
AdmissionRepository | list({page, limit, status}), stats() → GET /crm/admissions/stats, get(id), create(dto), update(id, patch), addDocument(id, dto), scheduleInterview(id, dto), decide(id, dto), convert(id) |
CampaignRepository | list({page, limit}), create(dto) |
- All map envelope →
data+meta(pagination mixin,00-shared/06 §3.2); errors surface asApiException(status, code, message, fieldDetails)(00-shared/07 §3). - Full endpoint table + rule codes:
12_API_Mapping.md §1,§4. - List cache keys:
crm.leads:{status}/crm.admissions:{status}/crm.campaigns(SWR, 5 min; detail never cached).
4. Domain logic (pure, unit-tested)
followUpSummary(lead) → {nextDue?, overdue}— earliest uncompletedscheduledAt(09 §3); drives S1 row subtitle + S2 overdue styling.convertRules(lead) → {pass, missing: [...]}— status ∉ {converted, closed} + gradeId/academicYearId/classId present (crm.service.ts:118-149); gates Convert button with tooltip (06 §S4).
5. Cubits
One cubit per screen/flow (13_State_Management.md §1), all pure-Dart, DI via
get_it lazy factories. Key policies:
- List cubits:
PaginatedListMixin+ status filter; client-side source/mine filters with "filtered on device" hint; refresh bypasses cache. - Detail cubits: no cache; pessimistic for convert/decision/follow-up; optimistic
only for field/status/assignment PATCHes with rollback (
13 §3). - Form cubits: 409-duplicate state (lead create) with "edit details" path; 400 field mapping; double-submit guard.
6. Routing
| Route | Page | Guard |
|---|---|---|
/crm → /crm/leads | LeadListPage | crm.read |
/crm/leads/new | LeadFormSheet | crm.lead.manage |
/crm/leads/:id | LeadDetailPage | crm.read |
/crm/admissions | AdmissionsPage | crm.read |
/crm/admissions/new | AdmissionFormPage | crm.lead.manage |
/crm/admissions/:id | AdmissionDetailPage | crm.read |
/crm/campaigns | CampaignsPage | crm.read |
/crm/campaigns/new | CampaignFormSheet | crm.campaign.manage |
- Permission gate:
permissionGuard('crm.read')etc. (strings frompermissions.constants.ts:34-36); server enforcement(planned). - Deep link: push tap →
/crm/leads/:id(forward-looking).
7. Widgets
Module components per 07_Component_Library.md; goldens for badges/timeline/stat
cards/tiles. No widget fetches data directly — cubit-provided state only
(00-shared/06 §2).
8. i18n
Keys under crm.* namespace (crm.status.*, crm.source.*, crm.adm.status.*,
crm.rule.* for the exact business-400 messages in 12 §4); server business text
fallback only for unmapped 4xx (00-shared/11 §9).
9. Offline policy
- Reads: SWR cache +
AppOfflineBanner; writes require connectivity (no offline queue — explicit design decision,13 §12); disabled submit buttons when offline with explanatory copy.
10. Testing plan
- Unit:
followUpSummary,convertRules, DTO mappers, form validators, cubits (mock repos): status filter, 409 duplicate, optimistic rollback, convert pre-check, decision gating. - Widget: three-state machine per list/detail; convert dialog disabled states;
workflow timeline rendering; goldens (
14 §13-14). - Integration: journey tests — lead capture → follow-up → qualify → convert;
admission submit → documents → interview → approve → convert; duplicate-email
banner flow (
03_User_Journey.md). - E2E: same journeys against live backend with seeded tenant
(parity with
14 §14).
11. Build order
- Enums/models + DTO mappers + repositories (contract-locked to
12 §1). LeadsCubit+ S1 (list/filter/pagination).- S2 detail + S3 form + 409 handling.
- Convert flow (rules + dialog) — server-rule parity tests.
- Admissions slice (S5-S9) + stats tiles.
- Campaigns slice (S10/S11).
- Permissions gating + deep links + i18n pass + performance profile.
01 — Product Overview (Dashboard Module)
- 1. What the module is
- 2. Scope in / scope out
- 3. Caching model (target, per blueprint)
- 4. Realtime / WebSocket
- 5. Role gating (derived from default roles)
- 6. Platform & client scope notes
- 7. Goals (product)
- 8. Non-goals (per source)
Aggregated, cached operational KPIs ("widgets, statistics, and cached KPIs" —
studylyon-blueprint/04-Modules/Dashboard.md:3). Derived from the blueprint module doc,src/modules/dashboard/**,src/modules/rbac/**(permissions.constants.ts,role.schema.ts),src/infrastructure/redis/ redis-cache.service.ts,src/shared/cache/cache-service.ts,src/modules/ws/**, anddocs/IMPLEMENTATION_PLAN.md. Nothing invented; plan-only capability is marked(planned), client-only or roadmap-only(forward-looking), analytics(proposed).
1. What the module is
The Dashboard module serves pre-aggregated operational KPIs to the
authenticated client: how many active students, teachers and staff; today's
attendance summary and rate; pending invoices and total dues. Per the
blueprint, KPIs are served from Redis, never live-scanned on request
(Dashboard.md:49), rebuilt by a scheduled worker plus on relevant domain
events (Dashboard.md:50), and widget visibility is role-gated via RBAC
(Dashboard.md:52).
Two collections: dashboard_widgets (widget config) and cache-backed KPI
values (Dashboard.md:7). Domain events consumed: AttendanceMarked,
ResultPublished, PaymentRecorded, InvoiceGenerated — to invalidate /
rebuild the cache (Dashboard.md:34). Dependencies: Redis KPI store
(TTL 60 s — Dashboard.md:41), Reports module for underlying aggregates,
WebSocket push (Dashboard.md:43).
What exists in code today vs. the blueprint
| Capability | Code today | Source |
|---|---|---|
GET /api/v1/dashboard/overview | Implemented | dashboard.controller.ts:13-17 |
| KPI aggregation (students/teachers/staff/attendance/finance) | Implemented | dashboard.service.ts:18-71 |
dashboard.read, dashboard.widget.manage permissions | Defined, not enforced on the route | permissions.constants.ts:37-38; dashboard.controller.ts:8 (JwtAuthGuard only) |
| Redis-backed KPI serving (TTL 60 s) | Missing — service runs live count()/find() scans per request | dashboard.service.ts:24-38; no cache calls |
GET /attendance, GET /finance, GET /widgets, PATCH /widgets/:id | (planned) — blueprint only | Dashboard.md:24-27 |
| Cache rebuild worker + event invalidation | (planned) — no worker, no event handler | Dashboard.md:50, Dashboard.md:34 |
Widget config collection dashboard_widgets | (planned) — no schema in code | Dashboard.md:7 |
| Teacher / student / parent dashboard views | (planned) | IMPLEMENTATION_PLAN.md:232 |
| Coaching KPIs (batch fill, test scores) | (planned) (Phase 6) | IMPLEMENTATION_PLAN.md:645, :707 |
2. Scope in / scope out
| In scope | Out of scope (owned elsewhere) |
|---|---|
| KPI aggregation: students, teachers, staff, attendance, finance | Raw attendance marking → Attendance module |
Cached overview contract + tenant-scoped cache keys (sl:{tenantId}:…) | Invoice lifecycle → Fees module |
Role-gated visibility (blueprint rule Dashboard.md:52) | Report generation → Reports module |
WS-driven cache invalidation hints (existing WsBridge) | Realtime delivery → WsModule (ws.gateway.ts) |
Widget customization (planned) | Push notifications / QR features (forward-looking) |
3. Caching model (target, per blueprint)
- Cache key namespaced by tenant:
sl:{tenantId}:dashboard:{scope}— theRedisCacheServiceprefixes every key with the current tenant automatically (redis-cache.service.ts:16-19), falling back toplatform(redis-cache.service.ts:17). - TTL 60 s per blueprint (
Dashboard.md:41). - Invalidation triggers:
AttendanceMarked,ResultPublished,PaymentRecorded,InvoiceGenerated(Dashboard.md:34). - Gap:
DashboardService.getOverview()performs live repo scans on every request (dashboard.service.ts:24-38) and never touches the cache. The UI must be built against the blueprint contract, with the live-scan behaviour treated as a temporary implementation detail (see 14_QA_Checklist.md §3).
4. Realtime / WebSocket
The existing WsGateway authenticates via JWT and joins the client to
tenant:{tenantId} on connect (ws.gateway.ts:42-50); WsBridge broadcasts
every domain event to that room (ws-bridge.service.ts:16-22). The
dashboard client can therefore listen for the four KPI-affecting event types
and trigger a cache-bypassing refresh. A server push of fresh KPI payloads
over WS is (forward-looking) — today the events carry event metadata, not
re-aggregated values (ws-bridge.service.ts:17-21).
5. Role gating (derived from default roles)
dashboard.read (permissions.constants.ts:37) and dashboard.widget.manage
(permissions.constants.ts:38) are granted by default to org_admin only
(role.schema.ts:23 — permissions: ALL_PERMISSIONS). The other default
roles carry no dashboard permissions: teacher :31, staff :39, accountant
:47, parent :55, student :63 (empty). platform_admin is cross-tenant
with an empty explicit list (:15).
Consequence: today the dashboard renders for the org admin; every other
role sees nothing until the tenant assigns them a role that includes
dashboard.read, or the server ships role-specific views (planned)
(IMPLEMENTATION_PLAN.md:232). The screens below therefore assume a
permission-filtered widget set (blueprint Dashboard.md:52), and
role-restricted finance widgets are enforced at the data layer (planned).
6. Platform & client scope notes
- Native mobile apps are excluded from Phase 1 of the PRD ("Native mobile
apps (web-first)",
PRODUCT_REQUIREMENTS_DOCUMENT.md:144, as referenced indesign-docs/users/01_Product_Overview.md:183-189); the shared ledger flags the whole Flutter design set as(forward-looking)(00-shared/12 A1). All screens here target the web-first responsive client and are(forward-looking)by extension. - API is
v1, Bearer JWT, tenant from token only (00-shared/07 §1,§6); wire contract per 00-shared/07 §2-§3. - Period selectors for charts, push of "dues due today", QR access:
(forward-looking); analytics(proposed)(00-shared/12 A4 — SDK open).
7. Goals (product)
- Admin gets the day at a glance — active students, teachers, staff, today's attendance %, pending invoices and dues in one screen, from cache, within one request.
- Numbers stay fresh without hammering MongoDB — 60 s TTL + event-driven
invalidation (blueprint
Dashboard.md:49-50). - Role-appropriate visibility — each role sees only the widgets its
permissions allow (blueprint
Dashboard.md:52). - Widget customization for admins
(planned)— arrange / toggle widgets (dashboard.widget.manage,IMPLEMENTATION_PLAN.md:232).
8. Non-goals (per source)
- No live DB scanning on request — blueprint rule (
Dashboard.md:49). - No report generation here (Reports module owns aggregates).
- No attendance/finance CRUD from the dashboard (thin links to modules).
- No realtime KPI push channel today — only event hints
(forward-looking). - No coaching-specific KPIs until Phase 6 (
IMPLEMENTATION_PLAN.md:645).
02 — User Personas (Dashboard Module)
- P1 — Anjali, Organization Admin (
org_admin) — primary persona - P2 — Ravi, Accountant (
accountant) - P3 — Meera, Teacher (
teacher) - P4 — Suresh, Parent (
parent) and Priya, Student (student) - P5 — Platform Admin (
platform_admin) - Permission → widget matrix (derived)
Role-aware dashboard personas. Access derived from default role permissions (
role.schema.ts:8-65) and dashboard permissions (permissions.constants.ts:37-38). Views marked(planned)are not implemented server-side. Personas marked(proposed)are analytics-relevant only.
P1 — Anjali, Organization Admin (org_admin) — primary persona
| Field | Detail |
|---|---|
| Context | Runs the school; sets policy, reviews ops daily. org_admin holds all permissions including dashboard.read and dashboard.widget.manage (role.schema.ts:23; permissions.constants.ts:37-38) |
| Access today | Full overview — active students, teachers, staff, today's attendance rate, pending invoices, total dues (dashboard.service.ts:50-70) |
| Job to be done | "What happened today, and what needs my attention, in 30 seconds" |
| Behaviour | Opens dashboard on login; reads KPI row left-to-right; taps a KPI card to drill into Students/Fees/Attendance screens; pulls-to-refresh when she hears a class was noisy; re-checks dues at month-end |
| Pain points | Numbers must be current within 60 s (cache TTL Dashboard.md:41); stale data without an indicator erodes trust; no trend column today — "up or down vs yesterday?" is answered by memory |
| Want (not yet) | Widget layout customization (planned) — dashboard.widget.manage exists (permissions.constants.ts:38) but no server API; coaching KPIs (planned) (IMPLEMENTATION_PLAN.md:645) |
| Analytics | dashboard.overview.open, dashboard.kpi.tap (proposed) |
P2 — Ravi, Accountant (accountant)
| Field | Detail |
|---|---|
| Context | Owns fee collection and reconciliation. Default role permissions: fees.collect, student.read (role.schema.ts:47) — no dashboard.read |
| Access today | No dashboard — permission check fails. Under (planned) role-specific views (IMPLEMENTATION_PLAN.md:232), the finance card is the target surface |
| Job to be done | "How much is outstanding today, and which invoices are stuck?" |
| Derived need | Finance widget: pendingInvoices count + totalDueAmount (dashboard.service.ts:66-69); drill to Fees module |
| Note | dashboard.read must be added to a role the tenant assigns, or the server must allow partial widget visibility per role (blueprint Dashboard.md:52) — design the UI to render a finance-only layout for this persona (planned) |
P3 — Meera, Teacher (teacher)
| Field | Detail |
|---|---|
| Context | Teaches classes; marks attendance. Default permissions: student.read, attendance.mark, attendance.edit (role.schema.ts:31) — no dashboard permission |
| Access today | No dashboard. Teacher view (planned) (IMPLEMENTATION_PLAN.md:232) |
| Derived need | Personal day: my classes today, my attendance summary, timetable link. None of the current aggregates (dashboard.service.ts:50-70) are teacher-scoped — requires a (planned) GET /dashboard/attendance scope by teacher |
| Behaviour | Would check attendance % at end of each class; wants it to reflect AttendanceMarked immediately (event Dashboard.md:34) |
P4 — Suresh, Parent (parent) and Priya, Student (student)
| Field | Detail |
|---|---|
| Context | Track child's attendance/dues. Parent: student.read (role.schema.ts:55); student: no permissions (role.schema.ts:63) |
| Access today | No dashboard |
| Derived need | Child-scoped attendance %, fee balance, notices. (planned) per IMPLEMENTATION_PLAN.md:232; child-scoping has no server support today — flag in QA (unknown ownership) |
| Note | Student-visible fees would need fees-related read permission; none exists for the student role (role.schema.ts:63) — (planned) gap |
P5 — Platform Admin (platform_admin)
| Field | Detail |
|---|---|
| Context | Cross-tenant super admin (role.schema.ts:10-16) |
| Access today | Not a per-tenant dashboard user; permissions list is empty :15 — platform-level rollups (planned), not defined in blueprint |
| Note | Out of client scope for Phase 1 |
Permission → widget matrix (derived)
| Widget | Data source (service) | Needed permission | Default roles with it |
|---|---|---|---|
| Students/Teachers/Staff counts | dashboard.service.ts:31-33 | dashboard.read (permissions.constants.ts:37) | org_admin only (role.schema.ts:23) |
| Attendance today | :34,:54-65 | dashboard.read | org_admin only |
| Finance: pending invoices + dues | :35-37,:45-48,:66-69 | dashboard.read + fees.collect-class read (planned) | org_admin; accountant has fees.collect but not dashboard.read (role.schema.ts:47) |
| Widget customization | — | dashboard.widget.manage (permissions.constants.ts:38) | org_admin only |
Unknowns: per-role widget filtering rules and the finance-read permission are
not defined in code → (planned); the client must render by what
permissions it has and hide what it doesn't (00-shared/06 §5 403 → hide).
03 — User Journey (Dashboard Module)
- J1 — Admin morning check ("the day at a glance") — primary
- J2 — Month-end finance review (admin / accountant
(planned)) - J3 — Attention-driven refresh (live events)
- J4 — Widget customization
(planned) - J5 — Empty / first day of tenancy
- J6 — Offline / degraded
- Journey map (mermaid)
- Failure points (tracked in
14_QA_Checklist.md)
End-to-end journeys for the dashboard. Routes/endpoints per 12_API_Mapping.md; states per 06_Screen_Specifications.md; WS behaviour per 13_State_Management.md. Steps marked
(planned)/(forward-looking)are not implemented today.
J1 — Admin morning check ("the day at a glance") — primary
| Step | Actor action | System / screen | Source |
|---|---|---|---|
| 1 | Opens app → lands on Dashboard Home | Session valid; OverviewCubit loads | 12_API_Mapping.md E1 |
| 2 | (implicit) | GET /api/v1/dashboard/overview → skeleton → KPI cards | dashboard.controller.ts:13-17 |
| 3 | Reads KPI row: students 812, teachers 34, staff 21, attendance 93%, dues ₹4.2L | Numbers + trend (planned) + stale badge | dashboard.service.ts:50-70 |
| 4 | Sees attendance 93% (summary present: …) | Taps attendance card → Attendance module (daily view) | link only; attendance data owned by Attendance module |
| 5 | Notices pending invoices count | Taps finance card → Fees module invoices list (filtered issued/partial/overdue — dashboard.service.ts:36-37) | link only |
| 6 | Pulls-to-refresh mid-morning | AppRefreshIndicator → cache-bypassing refetch | 10_Interaction_Specification.md §2 |
| 7 | Leaves tab open; worker marks attendance elsewhere | WS event AttendanceMarked (Dashboard.md:34) arrives on tenant:{id} room (ws.gateway.ts:50) → cubit invalidates overview → silent refresh | 13_State_Management.md §5 |
Happy path time: ~30 s glance; failures degrade to AppEmptyState /
AppErrorState with retry (06_Screen_Specifications.md §4).
J2 — Month-end finance review (admin / accountant (planned))
| Step | Actor action | System / screen |
|---|---|---|
| 1 | Opens dashboard, reads finance.pendingInvoices + totalDueAmount | KPI cards (dashboard.service.ts:66-69) |
| 2 | Clicks finance card | Fees module (list) — filter mirrors service statuses issued/partial/overdue |
| 3 | Returns → dashboard refreshes on focus (didChangeDependencies re-fetch) | cache TTL 60 s (Dashboard.md:41) |
| 4 | (Accountant persona) (planned): dashboard shows finance-only layout | needs dashboard.read on accountant role (02_User_Personas.md P2) |
J3 — Attention-driven refresh (live events)
| Step | Actor action | System / screen |
|---|---|---|
| 1 | Dashboard open while cashier records a payment | PaymentRecorded/InvoiceGenerated broadcast (Dashboard.md:34; ws-bridge.service.ts:16-22) |
| 2 | Cubit receives event → invalidates overview key → refetches | 13_State_Management.md §5 |
| 3 | Finance card updates without manual pull | in-place animation Mo-m-base |
J4 — Widget customization (planned)
| Step | Actor action | System / screen |
|---|---|---|
| 1 | Opens "Customize" (only if permission dashboard.widget.manage — permissions.constants.ts:38) | Widget palette sheet (planned) |
| 2 | Toggles attendance chart off, reorders finance card | GET /dashboard/widgets + PATCH /dashboard/widgets/:id (planned) (Dashboard.md:26-27) |
| 3 | Saves → overview refetches with new order | cache invalidated server-side (planned) |
| 4 | (No permission) | "Customize" hidden — never a 403 dead-end (00-shared/06 §5) |
J5 — Empty / first day of tenancy
| Step | Actor action | System / screen |
|---|---|---|
| 1 | Fresh tenant, no data | counts 0, attendance rate 0 (dashboard.service.ts:64 returns 0 when no records), no invoices |
| 2 | Screen shows zero-state cards + AppEmptyState hint "Invite your first student" | 06_Screen_Specifications.md §4 |
J6 — Offline / degraded
| Step | Actor action | System / screen |
|---|---|---|
| 1 | No network on open | AppOfflineBanner + last-good cached KPI payload rendered (TTL-flagged) |
| 2 | Tap retry | banner persists until success (00-shared/05, offline baseline) |
Journey map (mermaid)
flowchart LR
A[Login] --> B[Dashboard Home]
B -->|tap KPI| C[Students / Fees / Attendance module]
B -->|pull-to-refresh| D[Refetch overview, bypass cache]
B -->|WS event| E[Invalidate + silent refetch]
B -->|Customize (planned)| F[Widget palette]
B -->|no data| G[Zero-state]
B -->|offline| H[Last-good + banner]
Failure points (tracked in 14_QA_Checklist.md)
- Cache staleness beyond 60 s (no worker yet — live scans today;
01_Product_Overview.md§3 gap). - WS event storms on
tenant:{id}room (all events broadcast —ws-bridge.service.ts:16-22) → debounce refetch. - Role without
dashboard.readsees nothing →(planned)role views.
04 — Information Architecture (Dashboard Module)
- 1. Position in the app
- 2. Content structure (from the overview contract)
- 3. Screens / routes
- 4. Widget grid model
- 5. Role-filtered IA
- 6. Naming / taxonomy
How the dashboard sits in the app IA and how its content is structured. Shared app chrome per 00-shared/05 (Global IA); widget-level structure derived from the overview payload (
dashboard.service.ts:50-70) and the blueprint API list (Dashboard.md:23-27).
1. Position in the app
- Dashboard Home is the post-login landing screen for roles with
dashboard.read(permissions.constants.ts:37; 02_User_Personas.md P1). Roles without it land on their first permitted module(planned). - Navigation: main rail/drawer item "Dashboard" (first item, 00-shared/05).
- KPI cards are read-only summaries with drill-through links — the dashboard never hosts CRUD; each card deep-links to its owning module.
2. Content structure (from the overview contract)
The single implemented endpoint returns one flat object
(dashboard.service.ts:50-70):
overview
├── students.total count status='active' (:31)
├── teachers.total count() (:32)
├── staff.total count() (:33)
├── attendance.total today's records (:34,:54)
│ ├── attendance.summary {status: count} (:40-43,:55-56)
│ └── attendance.rate present/total×100, round (:57-64)
└── finance.pendingInvoices status in issued/partial/overdue (:35-37,:67)
└── finance.totalDueAmount Σ(totalAmount − paidAmount) (:45-48,:68)
3. Screens / routes
| Route | Screen | Source | Status |
|---|---|---|---|
/dashboard | Dashboard Home (KPI row + chart grid + widget grid) | dashboard.controller.ts:13-17 | Implemented (overview only) |
/dashboard/attendance | Attendance trend detail (planned) | Dashboard.md:24 | (planned) |
/dashboard/finance | Finance trend detail (planned) | Dashboard.md:25 | (planned) |
/dashboard/customize | Widget customization (planned) | Dashboard.md:26-27; permissions.constants.ts:38 | (planned) |
| drill-through | /students, /fees/invoices, /attendance (module screens) | external links | Implemented |
4. Widget grid model
- Desktop/tablet: responsive grid (
00-shared/05 §4); widgets laid out by priority, default order mirrors the service response order (students → teachers → staff → attendance → finance). - Phone: single-column stack; KPI row condenses to horizontally scrolling cards; charts become full-width blocks.
- Widget order/customization
(planned)— persisted server-side indashboard_widgets(Dashboard.md:7) onceGET/PATCH /widgetsexist (Dashboard.md:26-27).
5. Role-filtered IA
- Content is filtered by the user's permissions, not by hard-coded UI logic:
finance card only when the role may see finance aggregates
(planned)(blueprintDashboard.md:52; see02_User_Personas.mdmatrix). - Today the effective matrix is "org_admin sees everything; everyone else
sees nothing" (
role.schema.ts:23vs:31,:39,:47,:55,:63) — role-scoped IA(planned)perIMPLEMENTATION_PLAN.md:232.
6. Naming / taxonomy
| Term | Meaning |
|---|---|
| KPI card | Number + label + trend (planned) + optional drill link |
| Trend | Δ vs previous period — no server support (planned) |
| Widget | A dashboard tile (KPI card or chart) |
| Stale badge | Payload older than TTL 60 s rendered from client cache (Dashboard.md:41) |
05 — Screen Inventory (Dashboard Module)
- Legend
- 1. Dashboard Home (
/dashboard) - 2. Attendance Detail (
/dashboard/attendance)(planned) - 3. Finance Detail (
/dashboard/finance)(planned) - 4. Widget Customization (
/dashboard/customize)(planned) - Shared components used
- Analytics events
(proposed) - Keyboard, landscape, tablet, desktop
Every dashboard screen, its intent, route, composition, states, permissions, platform behaviour and events. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics.
Legend
States = idle / loading / success / empty / error(offline, rate, invalid) /
disabled / permission. Analytics events follow {module}.{screen}.{action}
(proposed); SDK open (00-shared/10 §8). Endpoints per 12_API_Mapping.md.
1. Dashboard Home (/dashboard)
| Field | Detail |
|---|---|
| Purpose | Role-filtered operational KPIs at a glance, from one cached endpoint |
| Entry | Post-login landing (roles with dashboard.read); any app navigation |
| Exit | drill-through to Students/Fees/Attendance; (planned) customize |
| Source | GET /api/v1/dashboard/overview (dashboard.controller.ts:13-17) → dashboard.service.ts:50-70 payload |
| Composition | KPI row (students/teachers/staff counts + attendance % + finance dues) (planned) chart blocks; (planned) widget grid |
| Permission | dashboard.read required (permissions.constants.ts:37); only org_admin has it by default (role.schema.ts:23) — no-permission → module not reachable, never a 403 dead-end |
| States | loading (skeletons), success, empty (zero counts — dashboard.service.ts:64 rate 0 path), error (offline/5xx with retry), stale (TTL badge) |
| Realtime | WS tenant:{id} room events (ws.gateway.ts:50; ws-bridge.service.ts:16-22) → invalidate + refetch for the 4 KPI events (Dashboard.md:34) |
| Analytics | dashboard.overview.open, dashboard.kpi.tap, dashboard.refresh (proposed) |
2. Attendance Detail (/dashboard/attendance) (planned)
| Field | Detail |
|---|---|
| Purpose | Attendance rate trend over a period |
| Source | GET /api/v1/dashboard/attendance (Dashboard.md:24) — no controller today |
| Composition | Period selector (planned); line/bar chart (AppCharts, 00-shared/03); today's summary (dashboard.service.ts:40-43) |
| Permission | dashboard.read; teacher-scoped variant (planned) (IMPLEMENTATION_PLAN.md:232) |
| States | as §1; empty = "no attendance yet" (rate 0 path :64) |
3. Finance Detail (/dashboard/finance) (planned)
| Field | Detail |
|---|---|
| Purpose | Pending invoices + dues trend |
| Source | GET /api/v1/dashboard/finance (Dashboard.md:25) |
| Composition | dues number, pending-invoice count, collection trend chart; drill to /fees/invoices |
| Permission | dashboard.read; accountant role (planned) (has fees.collect role.schema.ts:47, lacks dashboard.read) |
| States | as §1 |
4. Widget Customization (/dashboard/customize) (planned)
| Field | Detail |
|---|---|
| Purpose | Admin reorders/toggles widgets per tenant |
| Source | GET /api/v1/dashboard/widgets, PATCH /api/v1/dashboard/widgets/:id (Dashboard.md:26-27); persistence in dashboard_widgets (Dashboard.md:7) |
| Permission | dashboard.widget.manage (permissions.constants.ts:38) — server API absent, entry hidden until it ships |
| Composition | widget palette (reorder handles, visibility toggles, role chips (planned)) |
| States | as §1 + saving (per-widget pending), conflict (concurrent edit) |
Shared components used
AppCard, AppSkeleton, AppEmptyState, AppErrorState, AppRefreshIndicator,
AppOfflineBanner, AppSnackbar, AppBadge, AppChips, AppCharts
(00-shared/03). Module-specific: KpiCard, TrendIndicator, ChartCard,
StaleBanner, WidgetPalette (planned) — defined in 07_Component_Library.md.
Analytics events (proposed)
dashboard.overview.{open,refresh,stale}, dashboard.kpi.{tap,drill},
dashboard.attendance.{open,period},
dashboard.finance.{open,period},
dashboard.widgets.{open,reorder,toggle,save}.
Keyboard, landscape, tablet, desktop
- Phone: single-column scroll; KPI row horizontal-scroll; landscape scrolls.
- Tablet (≥840 dp): two-column KPI grid + chart side-by-side.
- Desktop: full widget grid (max 3 columns), hover on KPI cards reveals drill affordance.
- No text input anywhere → keyboard concerns only for
(planned)customize search field.
06 — Screen Specifications (Dashboard Module)
- 1. Dashboard Home
/dashboard - 2. Attendance Detail
/dashboard/attendance(planned) - 3. Finance Detail
/dashboard/finance(planned) - 4. Widget Customization
/dashboard/customize(planned) - Shared micro-interaction & motion notes (all screens)
Production-grade specs for each dashboard screen: chrome, states, components, micro-interactions, a11y, responsive, dark/light, motion. Tokens:
Sp= 00-shared/02 §3,Ty= typography §2,El= elevation §5,R= radius §4,Mo= motion §6. All components reference 00-shared/03. Data shapes citedashboard.service.ts; payload contract in 12_API_Mapping.md E1.
1. Dashboard Home /dashboard
Chrome: standard AppBar (title "Dashboard", elevation El-1), optional
subtitle line "Last updated Xs ago" when serving from cache; body scrolls
(CustomScrollView), SafeArea respected; horizontal padding 16 (phone),
24 (tablet+). No FAB except on (planned) customize entry (IconButton in
AppBar, shown only with dashboard.widget.manage — permissions.constants.ts:38).
1.1 Layout (phone portrait)
AppBar "Dashboard" [.. customize (planned, perm-gated)]
RefreshIndicator wraps content ↓
─────────────────────────────────────
Section: KPI row (horizontal scroll)
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│Students│ │Teachers│ │ Staff │ │Attend. │ │ Dues │
│ 812 │ │ 34 │ │ 21 │ │ 93% │ │ ₹4.2L │
│ ▲ +2 │ │ — │ │ — │ │ ▓ 91% │ │ ▲ +8k │
└────────┘ └────────┘ └────────┘ └────────┘ └────────┘
KpiCard×5: value headlineMedium; label bodySmall;
TrendIndicator (planned) bodySmall success/error/neutral
─────────────────────────────────────
Section: Attendance summary (planned chart)
ChartCard "Attendance — today"
bar/line per status breakdown (summary:status→count,
dashboard.service.ts:40-43) or rate line (:57-64)
PeriodSelector (planned): Today | 7d | 30d (segmented)
─────────────────────────────────────
Section: Finance card (planned trend)
ChartCard "Finance" — dues number + pendingInvoices
chip "3 overdue" (status issued/partial/overdue,
dashboard.service.ts:36-37)
─────────────────────────────────────
Section: widget grid (planned, perm-gated)
1.2 KPI cards — content contract
Each KPI card binds to one leaf of the payload (dashboard.service.ts:50-70):
| Card | Value | Source line | Format |
|---|---|---|---|
| Students | students.total | :31 (count active) | integer, NumberFormat |
| Teachers | teachers.total | :32 | integer |
| Staff | staff.total | :33 | integer |
| Attendance | attendance.rate | :57-64 | % (server pre-rounds) |
| Dues | finance.totalDueAmount | :45-48,:68 | currency, locale INR |
| Pending invoices | finance.pendingInvoices | :36-37,:67 | integer badge on Dues card |
Trend (Δ vs previous period) has no server field → render only when a
(planned) trend field ships; until then show neutral — and never fake a
delta. Trend style: success up, error down, neutral flat (00-shared/02).
1.3 States
- loading (first load):
AppSkeleton— KPI row as 5 square cards (value line 28 dp, label line 12 dp, shimmerMo.m-fast); charts asAppSkeleton(chart)blocks. No partial numbers. - success: cards fade/slide in
Mo-m-basestaggered (30 ms apart); charts animate drawMo-m-base. - empty (fresh tenant): counts render as real
0(the API returns zeros —dashboard.service.ts:31-33,64), each zero card shows a muted hint: "Invite your first student"; attendance card shows "No attendance today" (rate 0 path:64); finance card "No pending invoices". A singleAppEmptyStatehero ("Your dashboard is ready — add your first data") for the all-zero case; never hide a 0 behind an error. - error (offline):
AppOfflineBannertop + last-good payload rendered withStaleBanner("Showing data from X min ago"); retry button. - error (5xx): first-load →
AppErrorState.code(INTERNAL) +Retry; with previous data → inlineAppSnackbar+ keep old numbers, no flash. - stale: payload served from client cache older than the 60 s TTL
(
Dashboard.md:41) but newer than 10 min →StaleBanner(info) with "Refresh" action; older → treat as offline. - permission: no
dashboard.read→ route inaccessible (module hidden), never an error screen (00-shared/06 §5).
1.4 Refresh / polling / WS
| Mechanism | Behaviour |
|---|---|
| Pull-to-refresh | AppRefreshIndicator → cubit refresh() — bypasses client cache (force network, 00-shared/06 §3.3) |
| Focus return | refetch when app resumes or route returns to dashboard (staleness check first) |
| Polling | background poll every 60 s while dashboard is the active tab and app is online; cancelled on dispose (13_State_Management.md §4) |
| WS invalidation | on AttendanceMarked / ResultPublished / PaymentRecorded / InvoiceGenerated (Dashboard.md:34) delivered to tenant:{id} (ws.gateway.ts:50) → debounced (2 s) refetch; charts animate in place Mo-m-base |
| Double-load guard | single in-flight request; queued trigger coalesces (13_State_Management.md §3) |
1.5 Charts ((planned) blocks; today only numbers ship)
- Type: attendance trend line/bar; finance collection line. Render via shared
AppCharts(00-shared/03); no chart library decision is module-owned. - A11y chart summaries: every chart has
semanticsLabeldescribing the data ("Attendance 93% today, 812 of 874 present") + abodySmallcaption text row (screen-reader-safe alternative,00-shared/09). - Empty chart: dashed-line placeholder + "No data for this period".
- PeriodSelector
(planned):SegmentedButtonToday/7d/30d; selection is cubit state, triggers refetch with?period=only when server supports it(planned)— until then client-side slicing of the overview is not possible (single today-only payload); selector stays disabled with tooltip.
1.6 A11y
- KPI cards: semantics
button(drill) +label, valuein one node; order value-first for screen readers ("93 percent, Attendance"). - Live region on value changes after WS refresh (announce only deltas > 1%).
- Trend
(planned): arrow glyphs have hidden semantics text ("up 2%"). - Touch targets ≥ 48 dp; dynamic type to 200% reflows KPI row to vertical.
- Color is never the only signal (trends also carry
▲/▼glyphs).
1.7 Dark/light & responsive
- Token-only theming; KPI cards
surfaceContainerLow(dark: same token), charts useprimary/secondary/tertiary(00-shared/02). - Tablet ≥840 dp: 3-col grid (KPI row 1×5→wrap 3+2, charts side-by-side).
- Desktop: max content width 1200; hover raises card
El-e2+ reveals drill affordance. - Landscape phone: charts keep 16:9, KPI row stays horizontal scroll.
1.8 Motion
- Route enter:
Mo-m-baseslide+fade; skeleton shimmerMo-m-fast; WS-updated values:AnimatedSwitcher200 ms; stale banner slide-in.
2. Attendance Detail /dashboard/attendance (planned)
| Concern | Specification |
|---|---|
| Chrome | AppBar "Attendance", back; body as §1 |
| Header | headlineMedium today's rate + summary chips (present/absent/… counts — dashboard.service.ts:40-43) |
| Chart | ChartCard period trend from GET /dashboard/attendance (Dashboard.md:24); PeriodSelector enabled (server param (planned)) |
| States | as §1; empty → "No attendance recorded yet" (rate-0 path :64) |
| A11y | chart caption + semantics as §1.5 |
| Teacher scope | (planned) (IMPLEMENTATION_PLAN.md:232) — "my classes" filter; no server support today |
3. Finance Detail /dashboard/finance (planned)
| Concern | Specification |
|---|---|
| Chrome | AppBar "Finance", back |
| Header | dues headlineMedium + pendingInvoices count + overdue chip (:36-37) |
| Chart | collection trend from GET /dashboard/finance (Dashboard.md:25) |
| Drill | "View invoices" → /fees/invoices (filters mirror status in issued/partial/overdue — dashboard.service.ts:36-37) |
| States | as §1; empty → "No pending invoices — all settled" |
| A11y | as §1.5 |
4. Widget Customization /dashboard/customize (planned)
| Concern | Specification |
|---|---|
| Entry | AppBar IconButton on Home, rendered only with dashboard.widget.manage (permissions.constants.ts:38) |
| Palette | list of widget cards with ReorderableListView handles + Switch visibility + role chips (planned) |
| Save | AppButton → PATCH /dashboard/widgets/:id per change (planned) (Dashboard.md:27); per-item saving state; conflict (another admin edited) → AppSnackbar + reload list |
| Persistence | server dashboard_widgets (Dashboard.md:7) (planned); no local-only layout — always server-authoritative |
| States | loading skeleton list; empty → "All widgets shown"; error → AppErrorState + retry |
| A11y | reorder handles have Move semantics; switches labeled with widget name |
Shared micro-interaction & motion notes (all screens)
Mo-m-baseroute transitions;AppBarelevationEl-1; cardsEl-e1(00-shared/02 §5-6).- Snackbar durations: success < 3 s, error ≥ 4 s (
00-shared/03 A). - No FAB on dashboard screens (customize is an AppBar action).
- Polling timer paused when app backgrounded (
WidgetsBindingObserver). - Safe areas: bottom nav height respected in authed context.
07 — Component Library (Dashboard Module)
- 1.
KpiCard - 2.
TrendIndicator(planned) - 3.
ChartCard - 4.
PeriodSelector(planned) - 5.
StaleBanner - 6.
WidgetPalette(planned) - 7.
KpiRow - Dependency map (module → shared)
- Design decisions (why)
Module-specific widgets for the dashboard. Shared primitives live in 00-shared/03 (
AppCard,AppSkeleton,AppEmptyState,AppErrorState,AppRefreshIndicator,AppOfflineBanner,AppSnackbar,AppBadge,AppChips,AppCharts,SegmentedButtonfamily). Everything here composes those primitives; token names per 00-shared/02.
1. KpiCard
| Purpose | One aggregate number with label and optional trend |
| Props | label: String, value: String (pre-formatted), trend: TrendData? (delta, direction (planned)), onTap: VoidCallback?, status: KpiStatus (normal/zero/stale) |
| Binding | value from payload leaf (dashboard.service.ts:50-70); formatting at call site (NumberFormat, INR for totalDueAmount :68) |
| Layout | AppCard; value headlineMedium; label bodySmall onSurfaceVariant; TrendIndicator under value; optional trailing drill icon |
| States | normal; zero (value 0 → muted hint line, e.g. "Invite your first student"); loading (parent renders AppSkeleton instead — card never shows while loading); stale (StaleBanner owned by screen, card gets surfaceContainerLow tint) |
| A11y | semantics node: label, value value-first ("93 percent, Attendance"); button when onTap; 48 dp min |
| Motion | press Mo-m-fast ripple; value change AnimatedSwitcher 200 ms |
2. TrendIndicator (planned)
| Purpose | Δ vs previous period |
| Props | delta: double, `direction: up |
| Rendering | bodySmall; ▲ +2 success / ▼ −1 error / — neutral (00-shared/02 color roles); never rendered without server trend field (06_Screen_Specifications.md §1.2) |
| A11y | glyph + hidden semantics "up 2 percent"; color never sole signal |
3. ChartCard
| Purpose | Title + chart + caption container |
| Props | title, chart: Widget (via shared AppCharts), caption: String? (a11y text row), trailing: Widget? (e.g. PeriodSelector) |
| Layout | AppCard padded 16; title titleMedium; caption bodySmall onSurfaceVariant below chart |
| Empty | dashed placeholder + "No data for this period" (AppEmptyState mini) |
| A11y | caption text row is the screen-reader alternative to the chart (00-shared/09) |
4. PeriodSelector (planned)
| Purpose | Today / 7d / 30d segmented control |
| Props | value, onChanged, enabled |
| Behaviour | disabled + tooltip "Periods arrive with the server API" until `GET /dashboard/attendance |
| State | selection lives in cubit (13_State_Management.md §3), triggers refetch |
5. StaleBanner
| Purpose | Surface cache staleness honestly |
| Props | age: Duration, onRefresh: VoidCallback |
| Rendering | AppBanner(info) "Showing data from X min ago" + Refresh text button; auto-hides after successful refetch |
| Rules | shown only when serving client-cached payload older than 60 s TTL (Dashboard.md:41); ≥10 min → treated as offline (06_Screen_Specifications.md §1.3) |
6. WidgetPalette (planned)
| Purpose | Customize grid: reorder + visibility + role chips |
| Compos | ReorderableListView of WidgetPaletteTile (drag handle, Switch, AppChips roles (planned)), save via AppButton |
| Data | GET/PATCH /dashboard/widgets (planned) (Dashboard.md:26-27), persisted in dashboard_widgets (Dashboard.md:7) |
| A11y | handles Move semantics; switch labels per widget |
| Entry gate | parent renders only with dashboard.widget.manage (permissions.constants.ts:38) |
7. KpiRow
| Purpose | Horizontal-scrolling row of KpiCards (phone) / wrapped grid (tablet+ ) |
| Behaviour | scrollable with Scrollbar; shows 5 cards in service order (students → teachers → staff → attendance → finance, dashboard.service.ts:31-33,34,35-37) |
| Loading | 5 square AppSkeletons |
| A11y | row semantics combined list; no auto-scroll |
Dependency map (module → shared)
KpiCard→AppCard; ChartCard→AppCard,AppCharts,AppEmptyState;
StaleBanner→AppBanner; WidgetPalette→AppChips,AppButton,AppSnackbar;
all loading→AppSkeleton; refresh→AppRefreshIndicator; offline→AppOfflineBanner.
Design decisions (why)
- No custom chart widget — shared
AppChartsowns chart rendering (00-shared/03); module supplies data + caption only. - Trend is optional-by-default — payload has no trend field
(
dashboard.service.ts:50-70); components degrade to—rather than inventing deltas. - Server-authoritative layout
(planned)— widget order always fromdashboard_widgets, never local state only.
08 — Form Specifications (Dashboard Module)
- 1. Widget Customization form
(planned) - 2. Period selector
(planned)— segmented, not a form - 3. Existing "inputs" (non-form)
- 4. Global form rules (applied when forms land)
The dashboard is a read-first surface: no forms exist today (the single endpoint is
GET /dashboard/overview—dashboard.controller.ts:13-17; no DTO directory exists undersrc/modules/dashboard/). This file specifies the two form-like surfaces that arrive with(planned)server work, plus the non-form "inputs" the screens already take. Validation rules follow the shared form baseline (00-shared/08).
1. Widget Customization form (planned)
Reached from /dashboard/customize (perm-gated by dashboard.widget.manage
— permissions.constants.ts:38). Server API (planned)
(Dashboard.md:26-27); persistence dashboard_widgets (Dashboard.md:7).
| Field | Control | Rules |
|---|---|---|
| Widget list | ReorderableListView (drag handles) | 1:1 with server widget ids; reorder is local until save |
| Visibility | Switch per widget | optimistic toggle + rollback on failure |
| Role chips | AppChips multi-select (planned) | "who may see this widget" — free-text chip input; server role-list param (planned) |
| Submit | AppButton "Save" | one PATCH /dashboard/widgets/:id per changed widget (planned); per-item saving state; conflict (409, concurrent edit) → AppSnackbar + reload |
States: idle (pristine, Save disabled) / dirty (Save enabled) / saving (per-item spinner) / saved (success snackbar < 3 s) / error (banner ≥ 4 s, keep local edits) / conflict (reload).
A11y: drag handles Move semantics; every switch has widget-name label;
focus moves to first invalid — none expected (no free-text validation).
2. Period selector (planned) — segmented, not a form
| Field | Control | Rules |
|---|---|---|
| Period | SegmentedButton Today/7d/30d | single-select, non-empty default "Today"; disabled + tooltip until server accepts ?period= on /dashboard/attendance / /dashboard/finance (planned) (Dashboard.md:24-25) |
State lives in the cubit (13_State_Management.md §3); changing period refetches; no optimistic rendering of unloaded periods (charts show skeleton).
3. Existing "inputs" (non-form)
| Input | Kind | Behaviour |
|---|---|---|
| Pull-to-refresh | gesture | bypasses client cache, force network (06_Screen_Specifications.md §1.4) |
| KPI tap → drill | tap target ≥ 48 | navigation only, no payload mutation |
| WS invalidation | passive listener | never user-visible as input |
4. Global form rules (applied when forms land)
- No form autofill needed (no personal data).
- Submit-once: double-tap guard (00-shared/08).
- Offline: forms disabled with
AppOfflineBanner(no offline queue for dashboard writes; 00-shared/06 §3.6). - Error copy maps
400 VALIDATION_ERRORfield-level,409conflict inline (00-shared/07 §3).
09 — User Behaviour (Dashboard Module)
- B1 — The glance is the unit of use
- B2 — Trust decays with staleness
- B3 — Event-synchrony expectation
- B4 — Zero-reading is a real state, not an error
- B5 — Card taps expect module context
- B6 — Roles expect their own dashboard
- B7 — Periodic check-ins > continuous watching
- B8 — Offline read-over-write preference
- B9 — Trust in server, not client math
Observed behavioural patterns the design must serve, derived from the KPI payload (
dashboard.service.ts:50-70), the caching contract (Dashboard.md:41,49-52), and the WS bridge (ws-bridge.service.ts:16-22). Behavioural patterns are statements of intent, not UI rules — those are in 10_Interaction_Specification.md.
B1 — The glance is the unit of use
- Admin behaviour: read KPI row in under 10 seconds, decide, leave. The screen must paint numbers first (skeleton → values in service order), no interstitial navigation or "loading module" steps.
- Consequence: KPI cards are the first paintable content; charts below the fold are non-blocking (06_Screen_Specifications.md §1.1).
B2 — Trust decays with staleness
- Users notice when numbers stop moving. The 60 s TTL (
Dashboard.md:41) plus client cache means a payload can render minutes old. Behavioural pattern: users double-check by re-reading a card after events they know happened (a payment was just recorded). - Consequence:
StaleBannerwith age + one-tap refresh; WS events trigger silent refresh so the number visibly moves when the underlying data does.
B3 — Event-synchrony expectation
- When the user knows
AttendanceMarked/PaymentRecorded/InvoiceGeneratedjust fired (Dashboard.md:34), they expect the dashboard to reflect it within seconds. The WS bridge already broadcasts every domain event totenant:{id}(ws-bridge.service.ts:16-22;ws.gateway.ts:50). - Consequence: listen for exactly those 4 event types; debounce to coalesce bursts (a bulk attendance mark fires many events in seconds).
B4 — Zero-reading is a real state, not an error
- Fresh tenants get real zeros (
dashboard.service.ts:31-33; rate 0 at:64). Users read "0" as "nothing set up yet", not "broken". - Consequence: zero cards carry muted setup hints; the all-zero dashboard
shows one
AppEmptyStatehero (06_Screen_Specifications.md §1.3).
B5 — Card taps expect module context
- A KPI number invites drill-down (dues → which invoices?). Behavioural pattern: users tap the number, not a separate link.
- Consequence: whole
KpiCardis tappable → students list, fees invoices (filtered toissued/partial/overdue—dashboard.service.ts:36-37), attendance day view. Dashboard stays read-only; the target module owns the interaction.
B6 — Roles expect their own dashboard
- Non-admin roles today have no
dashboard.read(role.schema.ts:31,39,47,55,63) yet behave as if a dashboard exists (teachers check their attendance; accountants check dues). - Consequence
(planned): role-scoped views perIMPLEMENTATION_PLAN.md:232; UI must already structure itself as a permission-filtered widget set (Dashboard.md:52) so the finance-only accountant layout is a data-driven outcome, not a rewrite.
B7 — Periodic check-ins > continuous watching
- Users check the dashboard on a beat (morning, after classes, month-end), not continuously. Polling every 60 s while visible + WS invalidation covers the gaps; background polling is wasteful.
- Consequence: poll timer pauses when app is backgrounded or route is not the dashboard (06_Screen_Specifications.md §1.4).
B8 — Offline read-over-write preference
- Reading is tolerated offline if the last-good numbers remain visible and honest. Users prefer a stale-but-labelled number over a dead screen.
- Consequence: last-good payload +
StaleBanner/AppOfflineBanner(06_Screen_Specifications.md §1.3); no writes in the module today (08_Form_Specifications.md §4).
B9 — Trust in server, not client math
- Users (admins) know numbers are aggregated server-side. The client must
format, never recalculate — attendance rate arrives pre-rounded
(
dashboard.service.ts:57-64), dues pre-summed (:45-48). - Consequence: no client-side derivation; trend deltas only when the server
ships them
(planned)(07_Component_Library.md §2).
10 — Interaction Specification (Dashboard Module)
- 1. Load sequence (first open)
- 2. Pull-to-refresh
- 3. Background polling
- 4. WebSocket-triggered refresh
- 5. KPI card tap → drill
- 6. Stale-data surfaces
- 7. Customize screen
(planned) - 8. Empty-state interactions
- 9. Keyboard / focus
- 10. Motion budget
Micro-interaction rules for the dashboard. Motion tokens
Mo.*per 00-shared/02 §6; shared interaction patterns per 00-shared/08. State flow per 13_State_Management.md; component contracts per 07_Component_Library.md.
1. Load sequence (first open)
- Route push
Mo-m-baseslide+fade;AppBarimmediately visible. - KPI row area renders 5
AppSkeletoncards (value 28 dp line + label 12 dp, shimmerMo-m-fast), charts renderAppSkeleton(chart). - Payload arrives → skeletons swap for values with staggered fade-in
(30 ms apart, row order = service order
dashboard.service.ts:31-33,34,35-37); chart blocks animate drawMo-m-base((planned)blocks). - Failure on first load →
AppErrorState.code+Retry; retry re-runs the sequence from 2. No partial numbers.
2. Pull-to-refresh
AppRefreshIndicatorwraps the scroll view; trigger calls cubitrefresh()withforce: true— bypasses client cache (00-shared/06 §3.3), network fetch, cache-write on success.- During refresh: cards stay painted (no skeleton flash); only the indicator
spins. Success → silent replace
AnimatedSwitcher200 ms. Failure with existing data →AppSnackbar(error)≥ 4 s, numbers unchanged. - Double pull while in-flight → ignored (single-flight guard, 13_State_Management.md §3).
3. Background polling
- Timer: 60 s (matches cache TTL
Dashboard.md:41), active only while the dashboard route is current and app is foregrounded. - Tick →
refresh()non-forced: if client cache age < 60 s, skip (no request). Else fetch. - App backgrounded (
WidgetsBindingObserver) → timer paused; on resume, staleness check runs immediately.
4. WebSocket-triggered refresh
- Client subscribed to
tenant:{tenantId}room (auto-join on connect —ws.gateway.ts:50); listens forAttendanceMarked,ResultPublished,PaymentRecorded,InvoiceGenerated(Dashboard.md:34). - Event arrives → debounce 2 s (coalesce bulk event bursts) →
refresh(force: true). - If the overview key was invalidated server-side
(planned), the fetch naturally re-reads Redis; today the service live-scans (dashboard.service.ts:24-38) so the refetch simply gets fresh numbers. - Value deltas ≥ 1% animate (
AnimatedSwitcher200 ms) + live-region announcement (a11y). Sub-1% deltas update silently. - Connection lost → rely on poll timer; banner
AppOfflineBannerafter 2 missed polls.
5. KPI card tap → drill
- Whole card is the target (≥ 48 dp). Tap →
Mo-m-basepress ripple → route push to owning module: students list / teachers list / staff list / attendance day view / fees invoices filteredissued|partial|overdue(dashboard.service.ts:36-37). - Back → dashboard refetches on return (staleness check), preserving scroll position.
- No permission for target module → card tap shows
AppSnackbar"You don't have access to this section" (rare; hidden-by-permission prefers to omit the card entirely(planned)).
6. Stale-data surfaces
StaleBanner(info) slides in when rendering client-cached payload older than 60 s (Dashboard.md:41); shows age ("2 min ago") +Refresh.- ≥ 10 min → treated as offline:
AppOfflineBanner+ data kept. - On any successful refetch the banner animates out
Mo-m-fast.
7. Customize screen (planned)
- Entry IconButton (perm-gated
dashboard.widget.manage—permissions.constants.ts:38); route push. - Reorder: drag handle press
Mo-m-fast; drop commits to list state;Saveissues per-widgetPATCH(Dashboard.md:27); per-item spinner; conflict → snackbar + reload. No auto-save on drag.
8. Empty-state interactions
- Zero KPI cards: muted hint line; tap still drills (empty target screens
show their own
AppEmptyState). - All-zero dashboard: hero
AppEmptyState"Your dashboard is ready — add your first data" with primary action → Students module.
9. Keyboard / focus
- Dashboard has no text fields (except
(planned)customize search);Taborder: KPI cards → charts (focusable with caption) → AppBar actions. - Enter activates focused card. Escape returns from detail routes.
10. Motion budget
| Interaction | Token |
|---|---|
| Route enter/exit | Mo-m-base |
| Skeleton shimmer | Mo-m-fast |
| Value update (WS/poll) | 200 ms AnimatedSwitcher |
| Stale/offline banner | slide-in Mo-m-fast |
Chart draw (planned) | Mo-m-base |
| Press ripple | Mo-m-fast |
11 — Design System Mapping (Dashboard Module)
- 1. Typography
- 2. Colour
- 3. Spacing & layout
- 4. Elevation & radius
- 5. Motion
- 6. Component → token table
- 7. Dark/light & dynamic type
- 8. Accessibility mapping
Token-to-component mapping for dashboard surfaces. All tokens authoritative in 00-shared/02 (Sp = spacing §3, Ty = typography §2, El = elevation §5, R = radius §4, Mo = motion §6); components in 00-shared/03 and 07_Component_Library.md; a11y baseline 00-shared/09.
1. Typography
| Role | Token | Usage |
|---|---|---|
| Screen title | Ty headlineSmall/titleLarge | AppBar "Dashboard" |
| KPI value | Ty headlineMedium (tabs) / numeric style | KpiCard.value |
| KPI label | Ty bodySmall onSurfaceVariant | KpiCard.label |
| Trend | Ty bodySmall (bold variant (planned)) | TrendIndicator |
| Chart title | Ty titleMedium | ChartCard.title |
| Chart caption | Ty bodySmall onSurfaceVariant | ChartCard.caption (a11y text) |
| Banner | Ty bodyMedium | StaleBanner, AppOfflineBanner |
| Empty state | Ty titleMedium + bodyMedium | AppEmptyState |
2. Colour
| Role | Token | Use |
|---|---|---|
| Card surfaces | surfaceContainerLow | KpiCard, ChartCard (both themes) |
| Value | onSurface | KPI numbers |
| Trend up/down | primary/error (semantic success/error), neutral onSurfaceVariant | TrendIndicator (planned) — glyphs accompany colour (09_User_Behaviour.md B-rule) |
| Charts | primary, secondary, tertiary | AppCharts series |
| Overdue/attention | error + errorContainer chip | dues overdue chip |
| Zero hint | onSurfaceVariant | zero-state hints |
| Stale banner | info secondaryContainer | StaleBanner |
| Offline | AppOfflineBanner shared tokens | offline |
All pairs pass AA on light + dark (00-shared/09 §2); no module-specific colours added.
3. Spacing & layout
| Token | Use |
|---|---|
Sp 16 (phone) / 24 (tablet+) | screen horizontal padding |
Sp 12 | between KPI cards (row), chart-card padding |
Sp 16 | chart-card inner padding |
Sp 24 | section gaps |
| KPI row | horizontal scroll phone; wrap grid tablet ≥840 dp; max 3 cols desktop ≤1200 width |
4. Elevation & radius
| Token | Use |
|---|---|
El-e1 | resting cards (KpiCard, ChartCard) |
El-e2 | desktop hover lift (06_Screen_Specifications.md §1.7) |
El-1 | AppBar |
R md | cards, banners, chips |
R full | trend chips, period segmented control |
5. Motion
Per 10_Interaction_Specification.md §10: Mo-m-base routes/charts,
Mo-m-fast shimmer/press/banners, 200 ms value swap. No custom curves.
6. Component → token table
| Component | Container | Text | Accent |
|---|---|---|---|
KpiCard | surfaceContainerLow, El-e1, R-md | value headlineMedium onSurface; label bodySmall onSurfaceVariant | trend primary/error |
ChartCard | same as card | title titleMedium; caption bodySmall | series primary/secondary/tertiary |
StaleBanner | secondaryContainer | bodyMedium onSecondaryContainer | info |
WidgetPaletteTile (planned) | surfaceContainerLow | title bodyLarge | switch primary |
KpiRow | — | — | — (layout only) |
7. Dark/light & dynamic type
- Token-only theming; both schemes derive from the same roles — no hard-coded hex anywhere (00-shared/02 §1).
- Dynamic type 200%: KPI row falls back to vertical list; charts keep aspect but caption reflows; tested in 14_QA_Checklist.md §5.
8. Accessibility mapping
| Requirement | Mapping |
|---|---|
| Contrast AA | tokens above; chart series differ by luminance + pattern (planned) |
| Chart summaries | ChartCard.caption text row + semanticsLabel (06_Screen_Specifications.md §1.5) |
| Touch targets ≥ 48 | all cards, handles, switches |
| Colour independence | trend glyphs + labels |
| Live regions | KPI value updates ≥ 1% |
12 — API Mapping (Dashboard Module)
- E1 — Dashboard overview (implemented)
- E2 — Attendance trend
(planned) - E3 — Finance trend
(planned) - E4 — List widgets
(planned) - E5 — Update widget
(planned) - E6 — Realtime (not REST)
- Rate limits (client-relevant, 00-shared/07 §4)
- Client contract summary
Exact endpoints per screen. Wire contract per 00-shared/07: base
/api/v1, Bearer JWT, success{success:true,message:"OK",data,meta?, timestamp,requestId}, error envelope with codes. Only shapes in code are used; blueprint-only endpoints marked(planned). Sources:dashboard.controller.ts,dashboard.service.ts,Dashboard.md,permissions.constants.ts,role.schema.ts,redis-cache.service.ts,ws-bridge.service.ts.
E1 — Dashboard overview (implemented)
| Endpoint | GET /api/v1/dashboard/overview (dashboard.controller.ts:13-17) |
| Guard | JwtAuthGuard only (dashboard.controller.ts:8) — RBAC dashboard.read is NOT enforced on this route today (permission exists at permissions.constants.ts:37; enforcement (planned), cf. IMPLEMENTATION_PLAN.md Phase 5 permissions audit) |
| Request | none; tenant from JWT only (never from body — 00-shared/07 §1) |
| Response | 200 envelope, data (dashboard.service.ts:50-70): {students:{total}, teachers:{total}, staff:{total}, attendance:{total, summary:{status:count}, rate}, finance:{pendingInvoices, totalDueAmount}} |
| Aggregates | students = count status:'active' (:31); teachers = count (:32); staff = count (:33); attendance = today's records {date: {$gte: startOfDay, $lt: tomorrow}} (:34), summary grouped by status (:40-43), rate = round(present/total×100), 0 when none (:57-64); finance = invoices with status in ['issued','partial','overdue'] (:35-37), totalDueAmount = Σ(totalAmount − paidAmount) (:45-48) |
| Errors | 401 UNAUTHENTICATED (missing/expired JWT); 429 RATE_LIMITED (api tier 100/min); 5xx INTERNAL (service has no custom errors) |
| Side effects | None today — no cache read/write, no event emission. Service live-scans repositories per request (dashboard.service.ts:24-38); blueprint target is Redis-served with TTL 60 s (Dashboard.md:41,49) — (planned) |
| Client | Dashboard Home first load; skeletons → values (06_Screen_Specifications.md §1.3) |
Cached response contract (planned) (blueprint target)
| Cache key | sl:{tenantId}:dashboard:overview — tenant namespacing automatic (redis-cache.service.ts:16-19); fallback platform (:17) |
| TTL | 60 s (Dashboard.md:41) |
| Invalidation | on AttendanceMarked, ResultPublished, PaymentRecorded, InvoiceGenerated (Dashboard.md:34) — handler (planned); plus scheduled rebuild worker (Dashboard.md:50) |
| Client mirror | client cache sl:{tenant}:dashboard:overview TTL 60 s (volatile, 00-shared/06 §3.3); force-refresh bypasses it (10_Interaction_Specification.md §2) |
| Staleness | client renders last-good + StaleBanner beyond 60 s; ≥10 min → offline banner (06_Screen_Specifications.md §1.3) |
E2 — Attendance trend (planned)
| Endpoint | GET /api/v1/dashboard/attendance (Dashboard.md:24) — no controller route in code |
| Request | `?period=today |
| Response | rate/summary series for period; today shape from dashboard.service.ts:54-65 |
| Cache | sl:{tenantId}:dashboard:attendance:{period} TTL 60 s (planned) |
| Client | SS2 attendance detail; PeriodSelector disabled until this ships (08_Form_Specifications.md §2) |
E3 — Finance trend (planned)
| Endpoint | GET /api/v1/dashboard/finance (Dashboard.md:25) |
| Request | ?period= (planned); accountant role access (planned) (role lacks dashboard.read — role.schema.ts:47) |
| Response | dues + pendingInvoices series; today shape dashboard.service.ts:66-69 |
| Cache | sl:{tenantId}:dashboard:finance:{period} TTL 60 s (planned) |
| Client | SS3 finance detail |
E4 — List widgets (planned)
| Endpoint | GET /api/v1/dashboard/widgets (Dashboard.md:26) |
| Guard | dashboard.read (permissions.constants.ts:37); reads user's widget layout |
| Response | ordered widget configs from dashboard_widgets (Dashboard.md:7) |
| Client | /dashboard/customize list + Home layout resolution (planned) |
E5 — Update widget (planned)
| Endpoint | PATCH /api/v1/dashboard/widgets/:id (Dashboard.md:27) |
| Guard | dashboard.widget.manage (permissions.constants.ts:38) — the only dashboard write permission; org_admin default (role.schema.ts:23) |
| Request | visibility / order / role chips (planned) |
| Errors | 404 RESOURCE_NOT_FOUND; 403 (no permission); 409 (concurrent edit) |
| Client | per-widget save with per-item spinner + conflict snackbar (10_Interaction_Specification.md §7) |
E6 — Realtime (not REST)
| Channel | WS /ws, JWT auth (ws.gateway.ts:20-23,35-44); client auto-joined to tenant:{tenantId} (ws.gateway.ts:50) |
| Events | AttendanceMarked, ResultPublished, PaymentRecorded, InvoiceGenerated (Dashboard.md:34) — broadcast by WsBridge for every domain event (ws-bridge.service.ts:16-22), payload {eventType, occurredAt, payload} (:17-21) |
| Client rule | filter to the 4 event types; 2 s debounce; force-refresh (10_Interaction_Specification.md §4) |
| Server push of fresh KPIs | (forward-looking) — bridge emits event metadata only |
Rate limits (client-relevant, 00-shared/07 §4)
| Tier | Limit | Notes |
|---|---|---|
| api | 100/min | overview/attendance/finance polling must stay within budget: 60 s poll = 1 req/min per screen, plus pull + WS-triggered refetches — acceptable |
| admin | 500/min | (planned) if widgets PATCH ships under admin tier |
Client contract summary
| Concern | Rule |
|---|---|
| Auth | Bearer JWT; 401 → single-flight refresh → replay; fail → session expiry (00-shared/06 §3.6) |
| Optimistic | none today (read-only); (planned) customize toggles optimistic with rollback (08_Form_Specifications.md §1) |
| Idempotency | GETs safe; PATCH widgets (planned) idempotent per widget |
| Offline | last-good payload + banners; no writes (09_User_Behaviour.md B8) |
| Formatting | client formats, never recalculates (09_User_Behaviour.md B9): rate pre-rounded :57-64, dues pre-summed :45-48 |
| Error mapping | 00-shared/06 §5: 401 reauth, 403 hide, 404 empty, 429 backoff poll, 5xx keep-last-good + snackbar |
| Role gating | dashboard.read today = org_admin only (role.schema.ts:23); screens hidden without permission; role views (planned) (IMPLEMENTATION_PLAN.md:232) |
13 — State Management (Dashboard Module)
- 1. State model
- 2. Cubits
- 3. Fetch flow (OverviewCubit)
- 4. Polling
- 5. WebSocket refresh path
- 6. Period selectors
(planned) - 7. Widgets state
(planned) - 8. Cache & staleness summary
- 9. Error mapping (from
00-shared/06 §5)
Cubit-based state per 00-shared/06 (Bloc/Cubit baseline, cache §3.3, auth/session §3.6). Sources:
dashboard.service.ts(payload),redis-cache.service.ts(server cache),ws-bridge.service.ts+ws.gateway.ts(realtime),Dashboard.md(TTL/events).
1. State model
sealed class DashboardState {
const DashboardState();
}
class DashboardInitial extends DashboardState {}
class DashboardLoading extends DashboardState {} // first load, skeletons
class DashboardLoaded extends DashboardState {
final Overview overview; // dashboard.service.ts:50-70 shape
final DateTime fetchedAt; // freshness tracking
final bool fromCache; // client cache hit?
final Duration cacheAge; // 0 when fresh network
}
class DashboardError extends DashboardState {
final AppError error; // UNAUTHENTICATED / INTERNAL / RATE_LIMITED
final Overview? lastGood; // keep-last-good on refresh failure
}
class DashboardOffline extends DashboardState {
final Overview lastGood; // ≥10 min old → offline banner
}
Overview mirrors dashboard.service.ts:50-70 exactly — no client-side
derivation (09_User_Behaviour.md B9).
2. Cubits
| Cubit | Owns | Source |
|---|---|---|
OverviewCubit | payload, freshness, refresh triggers | E1 (12_API_Mapping.md) |
PeriodCubit (planned) | Today/7d/30d selection | 08_Form_Specifications.md §2 |
WidgetsCubit (planned) | widget order/visibility for customize screen | E4/E5 |
WsSubscriptionCubit (or service) | WS connect state, event filter, debounce | §5 below |
Single OverviewCubit per dashboard route; created in the screen's
BlocProvider, disposed on route pop (poll timer dies with it).
3. Fetch flow (OverviewCubit)
flowchart TD
A[open /dashboard] --> B{cache hit? age<60s}
B -->|yes| C[emit Loaded fromCache=true cacheAge=age]
B -->|no| D[emit Loading only if no lastGood]
D --> E[GET /dashboard/overview]
E -->|200| F[write client cache TTL 60s]
F --> G[emit Loaded fromCache=false]
E -->|401| H[session refresh → replay; fail = session expiry]
E -->|429| I[emit Error RATE_LIMITED, keep lastGood, backoff poll]
E -->|5xx| J[emit Error INTERNAL keep lastGood]
C --> K[poll timer 60s → B]
- Single-flight: one in-flight request; a new trigger while in-flight
sets a
pendingflag and re-runs once on completion (coalescing) (10_Interaction_Specification.md§2). refresh({force}): force skips the cache check (pull-to-refresh, WS).- Client cache key mirrors server:
sl:{tenant}:dashboard:overviewTTL 60 s (00-shared/06 §3.3); volatile — never written on error responses.
4. Polling
Timer.periodic(60s)started onLoaded, cancelled inclose().- Tick →
refresh()non-forced (cache age check skips the request when fresh —10_Interaction_Specification.md§3). - App-background:
WidgetsBindingObserverpauses timer; resume runs an immediate staleness check.
5. WebSocket refresh path
sequenceDiagram
participant S as Socket (WsGateway)
participant C as OverviewCubit
participant R as REST /dashboard/overview
Note over S: auto-joined tenant:{id} (ws.gateway.ts:50)
S->>C: event {eventType, occurredAt, payload} (ws-bridge.service.ts:17-21)
C->>C: filter: AttendanceMarked|ResultPublished|PaymentRecorded|InvoiceGenerated (Dashboard.md:34)
C->>C: debounce 2s (coalesce bursts)
C->>R: refresh(force: true)
R-->>C: fresh Overview
C->>C: emit Loaded; delta ≥1% → animate + live-region
- Unknown/other events ignored (bridge forwards every domain event —
ws-bridge.service.ts:16-22). - WS drop → poll timer continues; after 2 missed polls →
AppOfflineBanner. - Server-pushed KPI payloads
(forward-looking).
6. Period selectors (planned)
PeriodCubitstate{period: today|7d|30d}; change → refetch/dashboard/attendance|finance?period=only when server supports it (E2/E3(planned)); until then the selector renders disabled (08_Form_Specifications.md§2).- Each period keeps its own cache entry
sl:{tenant}:dashboard:{scope}:{period}(TTL 60 s(planned)).
7. Widgets state (planned)
WidgetsCubit:{widgets: List<WidgetConfig>, dirty: bool, savingIds: Set}; reorder/toggle → local optimistic + dirty; Save → per-widgetPATCH(E5) with rollback; conflict (409) → reload from E4.- Layout is server-authoritative (
07_Component_Library.md§7).
8. Cache & staleness summary
| Layer | Key | TTL | Writes on |
|---|---|---|---|
Server (target (planned)) | sl:{tenantId}:dashboard:overview | 60 s (Dashboard.md:41) | rebuild worker + event invalidation (Dashboard.md:50,:34) |
| Client volatile | sl:{tenant}:dashboard:overview | 60 s | successful 200 only |
| Client render | — | — | last-good always kept; StaleBanner past 60 s, offline ≥10 min |
9. Error mapping (from 00-shared/06 §5)
| Error | UI |
|---|---|
| 401 | silent refresh → replay; fail → session expiry screen |
| 403 | never reachable (module hidden) — dashboard.read gate |
| 429 | backoff: skip next poll cycle; AppSnackbar once |
| 404 | not expected on overview; treat as 5xx |
| 5xx | keep lastGood; snackbar ≥ 4 s; no skeleton flash |
14 — QA Checklist (Dashboard Module)
- 1. Data correctness (what the numbers mean)
- 2. Role gating & permission behaviour
- 3. Caching staleness (critical)
- 4. Interaction states
- 5. Accessibility (per 00-shared/09)
- 6. Performance
- 7. Offline & degraded
- 8. Blueprint-conformance regressions to track
Verification baseline for dashboard screens. Builds on 00-shared/10 (QA baseline) and 00-shared/09 (a11y). Items cite the source they guard.
1. Data correctness (what the numbers mean)
-
Students card shows active-only count (
status:'active'—dashboard.service.ts:31), not all students. -
Attendance is today-only (
{$gte: startOfDay, $lt: tomorrow}—:34); rate = server-roundedpresent/total×100,0when no records (:57-64). -
Finance card counts only
issued|partial|overdueinvoices (:36-37) and sumstotalAmount − paidAmount(:45-48); currency formattedINR. - Client never recalculates; it formats only (09_User_Behaviour.md B9) — assert in widget tests that the raw payload values are displayed verbatim.
-
Zero states are real zeros, not errors (
:31-33,:64).
2. Role gating & permission behaviour
-
With
org_admin(all perms —role.schema.ts:23): full overview renders. -
Roles without
dashboard.read(teacher :31,staff :39,accountant :47,parent :55,student :63): dashboard route hidden, no 403 dead-end (00-shared/06 §5). -
Finance widgets must be role-restricted when server-side widget filtering ships
(planned)(Dashboard.md:52); accountant finance-only layout(planned). -
Customize entry visible only with
dashboard.widget.manage(permissions.constants.ts:38); absent permission → entry not rendered. -
Open question: RBAC guard is not yet enforced on
GET /dashboard/overview(dashboard.controller.ts:8— JwtAuthGuard only) — track enforcement(planned)(permissions audit,IMPLEMENTATION_PLAN.mdPhase 5).
3. Caching staleness (critical)
-
Server TTL 60 s (
Dashboard.md:41) — verify the overview keysl:{tenantId}:dashboard:overviewexpires on time(planned); today the service live-scans (dashboard.service.ts:24-38) — regression guard: if/when caching lands, assert no live scan on warm cache. -
Event invalidation: after
AttendanceMarked/PaymentRecorded/InvoiceGenerated/ResultPublished(Dashboard.md:34) the next fetch returns fresh data(planned). -
Client: stale data past 60 s shows
StaleBannerwith correct age; ≥10 min → offline treatment (06_Screen_Specifications.md §1.3). - Client cache never written on error; last-good survives refresh failure.
- WS-triggered refetch is debounced (2 s) — bulk attendance marking (many events per second) coalesces to ≤ a few requests (10_Interaction_Specification.md §4).
- Poll budget: 60 s poll + pull + WS ≤ api tier 100/min (12_API_Mapping.md rate limits).
4. Interaction states
-
First load: skeletons only, no partial numbers; failure →
AppErrorState+ retry. - Pull-to-refresh: no skeleton flash; success silent, failure keeps numbers + snackbar ≥ 4 s.
- Single-flight: double pull/in-flight triggers coalesce, no duplicate requests.
-
KPI tap drills to owning module with matching filters (
issued/partial/overdue—:36-37). - Back from drill: dashboard refreshes by staleness check, scroll preserved.
-
Customize
(planned): optimistic toggle rollback on failure; conflict (409) reload; per-widget saving indicator.
5. Accessibility (per 00-shared/09)
-
Chart summaries: every
ChartCardhas a text caption +semanticsLabel("Attendance 93% today, 812 of 874 present") — screen readers never hit a silent chart (06_Screen_Specifications.md §1.5). -
KPI card semantics: value-first single node ("93 percent, Attendance"),
buttonwhen tappable. - Live region announces KPI deltas ≥ 1% after WS/poll updates; silent below.
-
Trend colour never sole signal:
▲/▼glyphs + hidden text (07_Component_Library.md §2). -
Touch targets ≥ 48 dp;
Taborder cards → charts → AppBar actions. - Dynamic type 200%: KPI row reflows vertical, no overflow; charts keep aspect, caption reflows.
- Dark/light AA contrast for all tokens incl. chart series (11_Design_System_Mapping.md §8).
6. Performance
- Dashboard paints KPI values before charts (values-first, 09_User_Behaviour.md B1); charts below fold load non-blocking.
-
No jank on WS value swap:
AnimatedSwitcher200 ms only; no full-list rebuild — cards keyed by widget id. -
Skeleton shimmer disabled on
reduceMotion(00-shared/02 §6). - Poll timer paused when backgrounded / route not active.
- One in-flight request at a time (single-flight) — no request storms.
-
Rebuilds:
KpiRowchildrenconst-eligible where possible;Overviewcompares via==to skip rebuilds.
7. Offline & degraded
-
Offline: last-good payload +
AppOfflineBanner; retry works after reconnect. -
No offline writes (module is read-only today; customize
(planned)disabled offline). - WS reconnect handled by socket layer; poll covers gaps; no duplicate refresh on reconnect + timer overlap (coalesce).
8. Blueprint-conformance regressions to track
-
"KPIs served from Redis, never live-scanned on request" (
Dashboard.md:49) — currently violated by the service; track until the caching layer lands (01_Product_Overview.md §3 gap). -
"Cache rebuilt by scheduled worker + on relevant events" (
Dashboard.md:50) — no worker exists(planned). -
"Role determines which KPIs are visible" (
Dashboard.md:52) — enforcement(planned).
15 — Flutter Implementation Guide (Dashboard Module)
- 1. Feature folder
- 2. OverviewCubit skeleton
- 3. Repository + cache
- 4. Screen skeleton
- 5. WS wiring (realtime refresh)
- 6. Responsive layout
- 7. Charts
(planned) - 8. Tests (map to
14_QA_Checklist.md) - 9. Rollout order
Build guidance for the dashboard feature slice. App architecture per 00-shared/11; state per 13_State_Management.md; components per 07_Component_Library.md; API per 12_API_Mapping.md. Native mobile is excluded from Phase 1 of the PRD (web-first) — this guide targets the responsive web client and is
(forward-looking)by extension (01_Product_Overview.md §6).
1. Feature folder
lib/features/dashboard/
├── dashboard_page.dart # route scaffold + BlocProvider
├── cubit/
│ ├── overview_cubit.dart # §2
│ ├── overview_state.dart # 13_State_Management.md §1
│ └── period_cubit.dart # (planned) §5
├── widgets/
│ ├── kpi_row.dart # KpiRow
│ ├── kpi_card.dart # KpiCard
│ ├── chart_card.dart # ChartCard (planned blocks)
│ ├── stale_banner.dart # StaleBanner
│ └── widget_palette.dart # (planned) customize
├── data/
│ ├── overview_repository.dart # GET /dashboard/overview + cache
│ └── overview_model.dart # mirrors dashboard.service.ts:50-70
└── dashboard_wire.dart # DI wiring (lazy route factory)
2. OverviewCubit skeleton
class OverviewCubit extends Cubit<DashboardState> {
OverviewCubit(this._repo) : super(DashboardInitial());
final OverviewRepository _repo;
Timer? _poll;
bool _inflight = false;
bool _pending = false;
Future<void> refresh({bool force = false}) async {
if (_inflight) { _pending = true; return; } // single-flight (13 §3)
_inflight = true;
try {
final (data, age) = force
? (await _repo.fetchNetwork(), Duration.zero)
: await _repo.getOrCached(); // cache age<60s → hit
emit(DashboardLoaded(overview: data, fetchedAt: DateTime.now(),
fromCache: age > Duration.zero, cacheAge: age));
} on AppError catch (e) {
emit(DashboardError(error: e, lastGood: _lastGood()));
} finally {
_inflight = false;
if (_pending) { _pending = false; unawaited(refresh(force: force)); }
}
}
void startPolling() => _poll ??= Timer.periodic(60s, (_) => refresh());
// close() cancels _poll — timer dies with the route (13 §4)
}
3. Repository + cache
class OverviewRepository {
final ApiClient _api; // 00-shared/07 contract
final CacheService _cache; // shared cache layer
static const _key = 'sl:{tenant}:dashboard:overview';
static const _ttl = Duration(seconds: 60); // mirrors Dashboard.md:41
Future<(Overview, Duration)> getOrCached() async {
final cached = await _cache.get<Overview>(_key);
if (cached != null && _cache.age(_key) < _ttl) return (cached, _cache.age(_key));
return (await fetchNetwork(), Duration.zero);
}
Future<Overview> fetchNetwork() async {
final res = await _api.get('/dashboard/overview'); // E1
final data = Overview.fromJson(res['data']);
await _cache.set(_key, data, ttlSeconds: _ttl.inSeconds);
return data;
}
}
Client cache key mirrors the server namespace sl:{tenantId}:…
(redis-cache.service.ts:16-19); volatile, 200-only writes (13_State_Management.md §8).
4. Screen skeleton
class DashboardPage extends StatelessWidget {
@override
Widget build(BuildContext context) => BlocProvider(
create: (_) => OverviewCubit(context.read())..refresh(),
child: const DashboardView(),
);
}
class DashboardView extends StatefulWidget { /* WidgetsBindingObserver for
poll pause on background (13 §4) */ }
class DashboardViewState ... {
build → Scaffold(
appBar: AppBar(title: 'Dashboard', actions: [ /* customize IconButton,
visible only with dashboard.widget.manage — permissions.constants.ts:38 */ ]),
body: AppRefreshIndicator(
onRefresh: () => cubit.refresh(force: true), // §2 interaction spec
child: BlocBuilder<OverviewCubit, DashboardState>(
buildWhen: (p, c) => p.overview != c.overview || p.runtimeType != c.runtimeType,
builder: (context, state) => switch (state) {
DashboardLoading() => const _SkeletonGrid(), // AppSkeleton ×5
DashboardError(:final lastGood) when lastGood != null =>
_KeepLastGood(lastGood, banner: true), // 13 §9
DashboardError() => AppErrorState(...),
_ => _DashboardContent(state as DashboardLoaded),
},
),
),
);
}
5. WS wiring (realtime refresh)
// In the screen state (initState):
_socket.onEvent((event) {
const kpiEvents = {'AttendanceMarked', 'ResultPublished',
'PaymentRecorded', 'InvoiceGenerated'}; // Dashboard.md:34
if (!kpiEvents.contains(event.eventType)) return; // filter (13 §5)
_debounce ??= Timer(2s, () => cubit.refresh(force: true)); // debounce bursts
});
Client is auto-joined to tenant:{tenantId} on connect
(ws.gateway.ts:50); events arrive via the existing socket layer
(ws-bridge.service.ts:17-21). No new server channel needed today;
server-pushed KPI payloads (forward-looking).
6. Responsive layout
LayoutBuilder(builder: (context, c) {
if (c.maxWidth >= 840) return const _DesktopGrid(); // 3-col, charts side-by-side
return const _PhoneColumn(); // KPI row scroll + stacked charts
});
KPI row: horizontal ListView on phone, wrapped grid on tablet/desktop
(04_Information_Architecture.md §4). Dynamic type 200% → vertical fallback
(11_Design_System_Mapping.md §7).
7. Charts (planned)
- Use shared
AppCharts(00-shared/03) — never a module-owned chart lib. - Every chart wrapped in
ChartCardwith caption +semanticsLabel(06_Screen_Specifications.md§1.5). DisablePeriodSelectoruntil server period params exist (E2/E3(planned)).
8. Tests (map to 14_QA_Checklist.md)
| Test | What it guards |
|---|---|
overview_cubit_test.dart | single-flight coalescing; cache-hit skips network; force bypasses cache; 5xx keeps lastGood; error never writes cache |
overview_repository_test.dart | key/TTL exact (sl:{tenant}:dashboard:overview, 60 s); 200-only writes |
dashboard_widget_test.dart | values rendered verbatim (no client math — 09 B9); zero-states show hints; stale banner age text |
kpi_card_semantics_test.dart | value-first node, button semantics, trend glyphs with hidden text |
| WS test (fake socket) | filter to 4 event types; 2 s debounce; ≥1% delta announcement |
| Perm test | customize entry hidden without dashboard.widget.manage; route hidden without dashboard.read |
9. Rollout order
- OverviewCubit + repository + KPI row (E1 live today).
- Stale/offline banners + WS refresh + polling.
- Charts/periods
(planned)when E2/E3 land. - Widget customization
(planned)when E4/E5 land. - Role-scoped layouts
(planned)perIMPLEMENTATION_PLAN.md:232.
01 — Product Overview (Exams Module)
- 1. Purpose
- 2. Business goals
- 3. User goals
- 4. Stakeholders
- 5. Why this exists
- 6. Scope boundaries (exact, from code)
- 7. Success metrics
- 8. Edge cases (backend-derived)
- 9. Assumptions (module)
- 10. Open questions (module; global ledger in 00-shared/12)
- 11. Glossary (this module)
StudyLyon — multi-tenant ERP / School Management API. This package designs the Exams module client (Flutter, forward-looking spec) against the implemented NestJS backend. Every endpoint, DTO field, schema, index, event, and permission is derived directly from
src/modules/exams/**,src/modules/results/**,src/infrastructure/bullmq/event-queue-map.ts,src/modules/rbac/permissions.constants.ts, anddocs/IMPLEMENTATION_PLAN.md. No feature is invented; gaps are flagged in Open Questions (§10) and marked(planned)/(forward-looking)/(proposed)inline.
1. Purpose
Exams is the academic evaluation backbone: exam planning (schedule per academic
year, type, status), subject scheduling (which class sits which subject on which
date/time), marks entry per (exam, student, subject), and publication that
releases results to the Results domain. The module owns two collections
(examinations, examination_subjects); marks live in the Results module's
examination_results collection (examination-result.schema.ts:7-8) — the Exams
service writes them directly (examination.service.ts:11, 176-182).
| Responsibility | Source |
|---|---|
Create an exam (always starts status: 'draft') | examination.service.ts:43-59 |
| List exams (paginated) / get one / update / soft-delete | examination.service.ts:61-110 |
| Add a subject-slot to an exam (class + date + time + marks) | examination.service.ts:112-131; examination-subject.schema.ts:9-31 |
| List an exam's subject-slots | examination.service.ts:133-137 |
| Enter/upsert marks for one student on one subject | examination.service.ts:139-195 |
| Publish an exam's results (atomic-ish, two writes) | examination.service.ts:203-220 |
| Read results by student / by exam-subject / report card | result.controller.ts:16-37; result.service.ts:36-98 |
| Grade letter computed from overall percentage | result.service.ts:130-138 |
Emit domain events (ExaminationCreated, MarksEntered, ExamResultsPublished, …) | examination.service.ts:50-57, 163-174, 212-219 |
ExamResultsPublished → BullMQ in-app queue, job results-published | event-queue-map.ts:27 |
2. Business goals
| Goal | Measure |
|---|---|
| Exam schedule creation per class/term is fast | One POST per exam (POST /examinations), one POST per subject slot (POST /examinations/:id/subjects) |
| Marks entry is per (exam, student, subject) and idempotent | Upsert by (tenantId, studentId, examinationSubjectId) unique index (examination-result.schema.ts:31-33); re-entry updates in place (examination.service.ts:147-175) |
| Marks never exceed the subject maximum | Server guard throws NotFoundException('Marks cannot exceed maximum.') when marksObtained > maximumMarks (examination.service.ts:145-146) — note: it surfaces as 404, client must pre-validate |
| Publication releases results atomically | publishResults stamps publishedAt on all results of the exam, then flips exam status → 'published' (examination.service.ts:203-220) |
| Multi-tenant isolation | Every query tenant-scoped by BaseRepository.scopedFilter (base.repository.ts:20-30); cross-tenant IDs → 404 |
| Auditability | All docs carry createdBy/updatedBy/version (base.schema.ts:13-31); $inc version on every update (base.repository.ts:57-66); domain events per mutation |
3. User goals
- Exam coordinator (admin/principal): create the term's exam calendar, attach
subject-slots per class, watch statuses
draft → active → completed → published, publish at the right moment. - Teacher: find my class's subject slot, enter marks for a whole class quickly, correct a wrong mark, never exceed the subject maximum.
- Student: see my marks per subject, open my report card (aggregate with percentage + overall grade).
- Parent: view the child's report card once published.
- Principal/analyst: see exam coverage (which subjects have/need marks) and the publish state of every exam.
4. Stakeholders
Exam coordinators (primary planners), teachers (marks entry), students & parents (consumers of published results), principal (publish authority), support staff (correction disputes), QA/design/engineering.
5. Why this exists
Exams are the highest-stakes academic workflow: the schedule drives the whole school calendar, marks become report cards, and a premature publish erodes trust. The backend provides a simple, idempotent CRUD + publish surface; the client must make planning and marks entry so efficient that teachers finish a class's marks in one sitting — and make publish an explicit, confirmable act.
6. Scope boundaries (exact, from code)
In scope today (backend):
POST/GET /examinations,GET/PATCH/DELETE /examinations/:id(examination.controller.ts:27-44)POST/GET /examinations/:id/subjects(examination.controller.ts:45-53)POST /examinations/:id/publish(examination.controller.ts:54-56)GET /results/student/:studentId,GET /results/exam-subject/:examSubjectId,POST /results/exam-subject/:examSubjectId/marks,GET /results/report-card/:studentId/:examId(result.controller.ts:16-37)- Domain events:
ExaminationCreated,ExaminationUpdated,ExaminationDeleted,ExaminationSubjectAdded,MarksEntered,ExamResultsPublished(examination.service.ts:50-57, 88-96, 102-110, 119-130, 163-174, 212-219)
Not implemented (flag labels used below):
POST/GET /examinations/:id/schedule,/hall-tickets,/seating-plan,/marks-import,POST/GET/PATCH /examinations/:id/re-evaluation—(planned);docs/IMPLEMENTATION_PLAN.md:213-220(Phase 4, "Missing Endpoints").- Mock tests, DPP, test series —
(planned); examtypeenum extension withmock_test | dpp | practice_test | all_india_test,isAllIndiaRank,testSeriesId, plustest_series/dppcollections andExamsService.createMockTest/createDPP/createTestSeriessketched indocs/IMPLEMENTATION_PLAN.md:501-567, 643. - RBAC enforcement — no
exam.*permission exists in code (permissions.constants.ts:1-97lists noexam.*; blueprintstudylyon-blueprint/04-Modules/Exams.md:64-72specifiesexam.create/update/ delete/mark/publishbut they are not seeded) andExaminationControllerdeclares no@Permissions()decorator (examination.controller.ts:21-24) — treat 403 as contract once RBAC lands (OQ-4). - Conflict detection with Timetable (
studylyon-blueprint/04-Modules/Exams.md:60) — no timetable check exists inexamination.service.tstoday (OQ-2). - Notifications for schedule/result alerts —
(planned); only thein-appresults-publishedjob is routed (event-queue-map.ts:27); other exam events have no queue route (event-queue-map.tscontains no entry forExaminationCreatedetc.). - Client-side analytics —
(proposed)per 00-shared/10_QA_Baseline.md §8.
7. Success metrics
- Coordinator creates an exam + 5 subject slots in < 3 min (6 requests).
- Teacher enters a 40-student class's marks in < 5 min — one POST per row, upsert safe for retries.
- 0 marks persisted above a subject maximum (server guard + client pre-validation).
- Publish round-trip < 1 s; post-publish read of results shows
publishedAtstamped. - Report card always renders a full subject list — missing results render as 0 marks
(
result.service.ts:68-81).
8. Edge cases (backend-derived)
- Marks exceed maximum →
NotFoundException('Marks cannot exceed maximum.')→ 404RESOURCE_NOT_FOUND, not 422 (examination.service.ts:145-146). Client must pre-validatemarksObtained ≤ maximumMarksand treat a 404 on save as a stale-maximum refresh. - Duplicate subject slot: no server-side uniqueness on
examination_subjects(index{tenantId, examinationId, classId}is not unique,examination-subject.schema.ts:37) and no duplicate check inaddSubject(examination.service.ts:112-131) — the same subject can be added twice; client should warn (OQ-3). - Subject date conflicts: no server check that a subject slot's date/time
collides with another slot or the timetable (
examination.service.tshas no conflict logic) — client-side warning only (OQ-2). - Publish immutability:
publishResultshas no guard — it can be re-called, andPATCH /examinations/:idcan still mutate apublishedexam (theUpdateExaminationDto.statusis a free string,examination.dto.ts:49-52; schema enum is not enforced on thefindOneAndUpdatepath becauseupdateByIdruns withoutrunValidators,base.repository.ts:57-66) — a bad status could persist (OQ-5). Client must treatpublishedas terminal in UI. - Entering marks before a subject exists → 404 "Exam subject not found."
(
examination.service.ts:144). - Report card with no subjects → 404 "No subjects found for this examination."
(
result.service.ts:51-53). - Report card missing results → those subjects count as 0 marks toward totals
(
result.service.ts:70); percentage can be 0 → gradeF(result.service.ts:83-95, 137). - Per-subject
gradeis client-supplied free text (EnterMarksDto.grade,examination-subject.dto.ts:57-60) — never computed server-side per subject; the only computed grade is the report-cardoverallGrade(A+/A/B+/B/C/D/F at 90/80/70/60/50/40,result.service.ts:130-138). passingMarksvsmaximumMarks: both@Min(1)only (examination-subject.dto.ts:36-44);passingMarks > maximumMarksis not rejected — client must validate (OQ-6).- Exam window inversion:
startDate > endDateis not validated (bothIsDateString,examination.dto.ts:19-25);startTime > endTimelikewise not validated (examination-subject.dto.ts:28-34) — client-side validation only (OQ-6). - Type not enum-validated on the wire:
CreateExaminationDto.typeisIsString(examination.dto.ts:13-17) though the schema enum is['midterm','final','unit_test','quarterly','other'](examination.schema.ts:15-20) — invalid values fail onsave()(create path runs validators) but a badtypemay 500 as a validator error; client constrains the picker. - Soft delete:
DELETE /examinations/:id→softDelete(examination.service.ts:99-110,base.repository.ts:68-74) — no cascade to subjects/results; deleted exam → 404 on every read path. Deleted-but-published results remain readable via Results routes. - Pagination:
GET /examinationssupportspage/limit(1–100, default 20), butsortandqare accepted yet ignored by the service (examination.service.ts:67-79usesfind({}, {skip, limit})only) — client-side sorting/filtering required (OQ-7).
9. Assumptions (module)
- Client is forward-looking (shared ledger A1 — PRD Phase 1 is backend; these docs specify the full Flutter client per user instruction).
- Marks entry scope: one
ExaminationResultper (student, subject-slot); the roster of students comes from the Students module (students ofclassIdon the subject slot) — no exam-specific roster endpoint exists. - Exam list is the module home; subject slots are always reached via an exam detail.
exam.*permissions will gate UI ((planned)per blueprint Exams.md:64-72); until the server enforces them, the client gates by role claim defensively but treats the server as authoritative (OQ-4).- Publish is a deliberate, confirmable act; the client never optimistically flips
status to
published(publish has side effects on Results visibility).
10. Open questions (module; global ledger in 00-shared/12)
| # | Item | Impact |
|---|---|---|
| OQ-1 | No exam "active/completed" transitions server-side — status is just a stored string; only create → draft and publish → published are forced (examination.service.ts:48, 209-211). Who flips active/completed and when? | Status picker/stepper UI |
| OQ-2 | Blueprint promises "conflict detection with timetable" (Exams.md:60) but no timetable check exists. Is a 422 BUSINESS_RULE_VIOLATION endpoint planned? | Subject date-conflict UX |
| OQ-3 | Duplicate subject slots are structurally possible (non-unique index). Server fix or client-side dedupe? | Add-subject guard UX |
| OQ-4 | No exam.* permissions seeded (permissions.constants.ts), no RBAC decorators on ExaminationController (examination.controller.ts:21-24). When does RBAC land? | Permission-gated UI visibility |
| OQ-5 | PATCH /examinations/:id accepts a free-string status (examination.dto.ts:49-52) and updateById skips runValidators (base.repository.ts:57-66) — bad status could persist; publish is re-callable. Server fix or client-only immutability? | Post-publish edit rules |
| OQ-6 | passingMarks > maximumMarks, startTime ≥ endTime, startDate > endDate are not server-validated. Client-only constraints? | Form validation strategy |
| OQ-7 | sort/q query params ignored on GET /examinations (examination.service.ts:67-79). Client-side sort/filter fallback? | List UX |
| OQ-8 | MarksEntered/ExaminationCreated/ExaminationUpdated/ExaminationDeleted/ExaminationSubjectAdded have no BullMQ route (event-queue-map.ts) — only ExamResultsPublished fires in-app/results-published. When do notifications land? | Result-published notification UX |
| OQ-9 | Per-subject grade is free text supplied by the client; subjectName in the report card is the raw subjectId string (result.service.ts:75). Are grade normalization and subject-name resolution planned? | Report card rendering |
11. Glossary (this module)
| Term | Meaning |
|---|---|
| Exam (Examination) | examinations doc: academicYearId, name, type, startDate, endDate, status, gradingSchemeId? (examination.schema.ts:9-36) |
| Exam type | midterm, final, unit_test, quarterly, other (examination.schema.ts:15-20) |
| Exam status | draft, active, completed, published (default draft, examination.schema.ts:28-33) |
| Subject slot (ExaminationSubject) | examinations_subjects doc: exam + subject + class + date + time window + maximumMarks/passingMarks (examination-subject.schema.ts:9-31) |
| Mark (ExaminationResult) | examination_results doc: student + slot + marksObtained/grade/remarks/publishedAt (examination-result.schema.ts:9-25) |
| Publish | POST /examinations/:id/publish — stamp publishedAt on all results, set exam status published (examination.service.ts:203-220) |
| Report card | GET /results/report-card/:studentId/:examId aggregate: per-subject rows + totals + percentage + overallGrade (result.service.ts:46-98) |
| Envelope | {success,message,data,meta?,timestamp,requestId} (shared 00-shared/07) |
02 — User Personas (Exams Module)
- 1. Exam Coordinator — "the planner"
- 2. Class Teacher — "the marker"
- 3. Principal — "the approver"
- 4. Student — "the candidate"
- 5. Parent — "the verifier"
- 6. Support / QA Analyst — "the verifier"
- 7. Persona × permission matrix (target contract,
(planned))
Six personas derived from the roles and permissions available in
src/modules/rbac/and the flows the exams API supports (examination.controller.ts,result.controller.ts). Permissions quoted are the available ones inpermissions.constants.ts:1-97— note noexam.*permission exists yet (OQ-4, see 01_Product_Overview.md §10); the RBAC column below is the target contract fromstudylyon-blueprint/04-Modules/Exams.md:64-72and is(planned)until seeded.
1. Exam Coordinator — "the planner"
| Who | Senior academic admin (principal, vice-principal, academic coordinator) at the tenant org |
| Roles / permissions | Admin role (all permissions, role.schema.ts:23); target: exam.create, exam.update, exam.delete, exam.publish (planned) |
| Frequency | Weekly during planning season; daily during results week |
| Device | Tablet + desktop web; office Wi-Fi |
| Goals | Create the term's exam calendar (name, type midterm/final/unit_test/quarterly/other — examination.schema.ts:15-20); attach subject slots per class (date + 09:00-style time window + maximumMarks/passingMarks); keep statuses honest (draft → active → completed → published); publish results only when every subject is marked |
| Pain points | No server-side conflict detection for subject dates (OQ-2) — they must eyeball overlaps; publish is irreversible in UI terms (examination.service.ts:203-220 sets status published); no bulk tools — each subject slot is one POST (examination.controller.ts:45-50) |
| Behaviors | Reviews the exam list (GET /examinations, paginated) by status; drills into a detail (GET /examinations/:id + GET /examinations/:id/subjects) to check marks coverage (GET /results/exam-subject/:id); uses the publish dialog as a final checklist |
| Exit criteria | Exam visible with correct slots; all marks entered; publish succeeded → snackbar + list shows published badge |
2. Class Teacher — "the marker"
| Who | Class teacher / subject teacher marking a class's answer sheets |
| Roles / permissions | Teacher role (role.schema.ts:31); target: exam.mark (planned) |
| Frequency | Daily during exam week; per subject-slot |
| Device | Phone + tablet; classroom Wi-Fi may be flaky |
| Goals | Open the slot for my class+subject, enter marksObtained per student fast (numeric keyboard, row-swipe cadence), fix typos in place, finish and verify coverage |
| Pain points | Marks above the slot maximum are rejected by the server with a 404 NotFoundException('Marks cannot exceed maximum.') (examination.service.ts:145-146) — confusing if not pre-validated; per-subject grade is free text (examination-subject.dto.ts:57-60) so they must type it or leave blank; no bulk marks endpoint today (/marks-import is (planned), IMPLEMENTATION_PLAN.md:219) |
| Behaviors | Enters marks row by row; re-taps a cell to correct (upsert updates in place — examination.service.ts:147-175); checks the coverage summary bar (marked / total students) before leaving |
| Exit criteria | Every roster student has a mark ≤ maximumMarks; coverage 100% |
3. Principal — "the approver"
| Who | Principal or director; owns the publish decision |
| Roles / permissions | Admin; target: exam.publish (planned) |
| Frequency | 2–6 times per term (per exam) |
| Device | Phone / tablet; on the move |
| Goals | Verify marks coverage across subjects, approve publish; afterwards, confirm students/parents can see results (publishedAt stamped on every result — examination-result.repository.ts:42-51) |
| Pain points | Publish has no guard against re-publish or post-publish edits (OQ-5) — trust in the UI's "published = locked" treatment; no server-side check that all marks are entered before publish (it publishes whatever exists — examination.service.ts:203-220) |
| Behaviors | Opens exam detail, glances at per-subject coverage, taps Publish, confirms in the dialog, watches for the published status flip |
| Exit criteria | Exam shows published; report cards reachable (GET /results/report-card/:studentId/:examId) |
4. Student — "the candidate"
| Who | Enrolled student of the tenant |
| Roles / permissions | Student role (role.schema.ts:63); reads via result.read (planned)/student.read today |
| Frequency | After each exam's publish; before each exam to see schedule |
| Device | Phone; shared data plan |
| Goals | See the exam schedule and their marks per subject (GET /results/student/:studentId), open the report card (percentage + overallGrade A+…F — result.service.ts:130-138), see grade/remarks per subject |
| Pain points | Results before publish are invisible server-side only if they never appear; a subject with no mark renders as 0 marks on the report card (result.service.ts:70) — must be explained in UI; marks are visible regardless of publish state through the student-results route (no visibility filter — result.repository.ts:26-28) |
| Behaviors | Checks the results tab after "results published" notification (in-app/results-published job — event-queue-map.ts:27 (planned)); screenshots the report card |
| Exit criteria | Report card shows totals, percentage, grade; understands why a subject shows 0 |
5. Parent — "the verifier"
| Who | Parent/guardian of an enrolled student |
| Roles / permissions | Parent role (role.schema.ts:55); reads via result.read (planned) / student.read today |
| Frequency | After each published exam |
| Device | Phone; home Wi-Fi |
| Goals | Verify the child's report card; compare marksObtained vs maximumMarks per subject; check remarks from teachers |
| Pain points | Same as student: unpublished results reachable via the route; subject-name resolution is raw subjectId in the report card payload (result.service.ts:75 — OQ-9) — client must resolve names locally |
| Behaviors | Passive reader; shares the card with family |
| Exit criteria | Full subject table + totals rendered from GET /results/report-card/:studentId/:examId |
6. Support / QA Analyst — "the verifier"
| Who | School IT support or QA reviewing tenant data |
| Roles / permissions | Org admin; audit.read (permissions.constants.ts:54) |
| Frequency | On incident |
| Device | Desktop |
| Goals | Trace a "wrong marks" complaint: check exam status, slot max marks, the student's result doc, and the version counter (base.schema.ts:30-31) to confirm how many edits happened; verify publish timestamps (publishedAt) |
| Pain points | Events like MarksEntered are emitted but not persisted to an audit queue (no queue route in event-queue-map.ts — OQ-8); soft-deleted exams are invisible after DELETE (base.repository.ts:68-74) |
| Behaviors | Replays the flow read-only; compares server state against the client's optimistic UI claims |
| Exit criteria | Root cause identified (data vs display) |
7. Persona × permission matrix (target contract, (planned))
| Action | Coordinator | Teacher | Principal | Student | Parent |
|---|---|---|---|---|---|
| List/view exams | ✅ exam.read* | ✅ exam.read* | ✅ | ✅ (read-only) | — |
| Create/update/delete exam | ✅ exam.create/update/delete | — | ✅ | — | — |
| Add subject slot | ✅ exam.update | — | ✅ | — | — |
| Enter marks | — | ✅ exam.mark | — | — | — |
| Publish | ✅ exam.publish | — | ✅ exam.publish | — | — |
| View own results/report card | — | — | — | ✅ result.read | ✅ result.read |
* exam.* / result.* permissions are not yet in permissions.constants.ts:1-97
(see 01_Product_Overview.md §10 OQ-4); the matrix is the design target, not the
current server contract.
03 — User Journey (Exams Module)
- 1. J1 — Plan an exam (Coordinator)
- 2. J2 — Enter marks (Teacher)
- 3. J3 — Publish results (Principal / Coordinator)
- 4. J4 — View own results (Student)
- 5. J5 — Correct a wrong mark (Teacher)
- 6. J6 — Edit / delete an exam (Coordinator)
- 7. J7 — Exam lifecycle glance (any role)
- 8. Journey → endpoint cheat sheet
End-to-end journeys mapped to exact API calls (
examination.controller.ts,result.controller.ts). Journey steps that rely on endpoints not yet implemented are marked(planned). Error paths quote real service messages (examination.service.ts,result.service.ts).
1. J1 — Plan an exam (Coordinator)
Goal: stand up a term exam with subject slots in under 3 minutes.
- Open Exams tab → list loads from
GET /api/v1/examinations?page=1&limit=20(examination.controller.ts:30-32). - Tap "New exam" → form (see 08_Form_Specifications.md §1):
academicYearId(MongoId),name,type(midterm|final|unit_test|quarterly|other,examination.schema.ts:15-20),startDate,endDate(CreateExaminationDto,examination.dto.ts:4-26).
- Submit →
POST /api/v1/examinations→ 201 doc withstatus: 'draft'(examination.service.ts:48) → navigates to exam detail. - Add slots: for each class+subject →
POST /api/v1/examinations/:id/subjectswith{examinationId, subjectId, classId, date, startTime ('09:00'), endTime, maximumMarks, passingMarks}(CreateExaminationSubjectDto,examination-subject.dto.ts:11-45).- Client warns if another slot overlaps date+time (no server check — OQ-2, 01_Product_Overview.md §10) and if the same subject is already added (no server dedupe — OQ-3).
- Client blocks
passingMarks > maximumMarksandstartTime ≥ endTime(OQ-6).
- List refresh shows the exam; badge
draft.
Success signal: detail screen lists N slots; list badge draft.
Failure paths: 400 VALIDATION_ERROR (bad ids/dates) → inline field errors;
409 not applicable today (no unique constraints on exam name/slot — OQ-3).
2. J2 — Enter marks (Teacher)
Goal: mark a full class in one sitting; correct errors in place.
- Open exam detail → tap a slot → marks screen (
GET /api/v1/examinations/:id/subjectsfor slot list, thenGET /api/v1/results/exam-subject/:examSubjectIdfor existing marks,result.controller.ts:21-25). - Roster = students of the slot's
classId(Students module); each row prefilled from the marks response (absent result = unmarked). - Type
marksObtained(numeric keyboard,@Min(0)—examination-subject.dto.ts:51-55), optionalgrade+remarks. - Save row →
POST /api/v1/results/exam-subject/:examSubjectId/markswith{studentId, marksObtained, grade?, remarks?}(EnterMarksDto,examination-subject.dto.ts:47-66) — upsert: existing(studentId, examinationSubjectId)result is updated in place (examination.service.ts:147-175). - Client pre-validates
marksObtained ≤ maximumMarks; if the server still returns 404 "Marks cannot exceed maximum." (examination.service.ts:145-146), treat as stale maximum → refresh slot, re-enter. - Coverage bar shows marked/total; teacher exits at 100%.
Success signal: every roster row has a saved mark; coverage bar 100%.
Failure paths: offline → row queued (module offline queue, 13_State_Management.md);
404 "Exam subject not found." (examination.service.ts:144) if slot deleted → return
to exam detail; 429 → backoff + retry.
3. J3 — Publish results (Principal / Coordinator)
Goal: release results for one exam; verify the published state.
- Exam detail → coverage check (per-slot
GET /api/v1/results/exam-subject/:idmarked/total counts). - Tap Publish → confirm dialog (explicit, never optimistic — publish has side effects: results become visible downstream).
POST /api/v1/examinations/:id/publish(examination.controller.ts:54-56): server stampspublishedAt = nowon every result of the exam's slots (examination-result.repository.ts:42-51) and sets examstatus: 'published'(examination.service.ts:209-211); emitsExamResultsPublished(examination.service.ts:212-219) →in-appqueue, jobresults-published(event-queue-map.ts:27).- Detail updates: status badge
published; slot marks showpublishedAt. - Students/parents notified
(planned)— only the in-app job route exists.
Success signal: badge published; marks rows show published timestamp.
Failure paths: 404 "Examination not found." (deleted/cross-tenant,
examination.service.ts:209 uses updateById on a missing id — no explicit
re-check; treat as refresh+return). Re-publish is possible server-side (no guard,
OQ-5) — UI hides the button after publish.
4. J4 — View own results (Student)
Goal: see marks per subject and the report card after publish.
- Results tab →
GET /api/v1/results/student/:studentId(result.controller.ts:16-20) → list of mark docs. - Open report card →
GET /api/v1/results/report-card/:studentId/:examId(result.controller.ts:32-37) → subject rows (name resolved client-side fromsubjectId— OQ-9), totals,percentage(2 dp),overallGrade(A+/A/B+/B/C/D/F,result.service.ts:130-138). - Missing marks render as 0 (
result.service.ts:70) with an explicit "not marked" hint — the payload cannot distinguish "0" from "absent".
Success signal: card renders with totals + grade.
Failure paths: 404 "No subjects found for this examination."
(result.service.ts:51-53) → empty state "No subjects scheduled yet."
5. J5 — Correct a wrong mark (Teacher)
Goal: fix a typo after saving.
- Marks screen → tap row → edit cell → save → same
POST /results/exam-subject/:examSubjectId/marksendpoint (upsert updatesmarksObtained/grade/remarks,examination.service.ts:151-161); docversionincrements (base.repository.ts:57-66). MarksEnteredevent re-emitted (examination.service.ts:163-174).- Snackbar "Mark updated"; no history shown (no per-field change log in payload —
only the event carries
examinationSubjectId+studentId).
Success signal: corrected value persists across re-fetch. Failure paths: same as J2; after publish, still editable server-side (OQ-5) — UI may allow but flag "published exam".
6. J6 — Edit / delete an exam (Coordinator)
- Exam detail → Edit →
PATCH /api/v1/examinations/:idwith partial{name?, type?, startDate?, endDate?, status?}(UpdateExaminationDto,examination.dto.ts:28-53). Client never sendsstatusexcept draft→active (OQ-1); disables status editing oncepublished(OQ-5). - Delete → confirm dialog →
DELETE /api/v1/examinations/:id→ soft delete (examination.service.ts:99-110,base.repository.ts:68-74); subjects/results are not cascaded — subject reads 404, but report cards keep working off the results collection. - Return to list; exam gone (filtered by
isDeleted: false,base.repository.ts:20-30).
Success signal: list no longer shows the exam; snackbar "Exam deleted".
Failure paths: 404 "Examination not found." (examination.service.ts:101, 87).
7. J7 — Exam lifecycle glance (any role)
- List (
GET /examinations, paginatedpage/limit,metafrombuildPaginationMeta,pagination-query.dto.ts:41-55);sort/qare ignored by the service (OQ-7) → client sorts bystartDatelocally. - Statuses map to badges:
draft(neutral),active(primary),completed(secondary),published(success) — schema enumexamination.schema.ts:28-33. - Row tap → detail (
GET /examinations/:id) → slots (GET /examinations/:id/subjects).
8. Journey → endpoint cheat sheet
| Journey | Primary endpoints |
|---|---|
| J1 Plan exam | POST /examinations, POST /examinations/:id/subjects, GET /examinations |
| J2 Enter marks | GET /examinations/:id/subjects, GET /results/exam-subject/:id, POST /results/exam-subject/:id/marks |
| J3 Publish | POST /examinations/:id/publish, GET /examinations/:id |
| J4 Own results | GET /results/student/:studentId, GET /results/report-card/:studentId/:examId |
| J5 Correct mark | POST /results/exam-subject/:id/marks (upsert) |
| J6 Edit/delete | PATCH /examinations/:id, DELETE /examinations/:id |
| J7 Lifecycle glance | GET /examinations, GET /examinations/:id, GET /examinations/:id/subjects |
04 — Information Architecture (Exams Module)
- 1. Placement in global nav
- 2. Information hierarchy
- 3. Screen tree
- 4. Route map (forward-looking Flutter routes)
- 5. Navigation rules
- 6. Content model (from schemas)
- 7. Naming conventions
- 8. Empty & edge IA states
App IA for the Exams module on top of the global structure in 00-shared/05_Global_Information_Architecture.md. The exams feature is a vertical slice under Academics: list → detail → slot → marks, with a read-only Results branch for students/parents. All routes are forward-looking client routes.
1. Placement in global nav
- Teachers / coordinator / admin:
Academics → Exams(list, detail, slot marks, publish) andAcademics → Results(report cards). - Student / parent:
My School → Results(own marks + report cards). No exam planning surfaces — read-only branch of the same endpoints (GET /results/student/:studentId,GET /results/report-card/:studentId/:examId,result.controller.ts:16-37). - Permission gating: server RBAC
(planned)—exam.*/result.*permissions not yet seeded (permissions.constants.ts:1-97, see 01_Product_Overview.md §10 OQ-4). Client gates by role claim; server authoritative when RBAC lands.
2. Information hierarchy
Academics
└── Exams (S1 — list)
└── Exam Detail (S2)
├── Slot Detail / Marks Entry (S3 — per subject slot)
│ └── (roster rows = Students module by classId)
├── Add Subject Slot (S4 — form, from detail FAB)
└── Publish (S5 — dialog, from detail action bar)
Results (shared read branch)
└── Student Results (S6 — marks list by studentId)
└── Report Card (S7 — per student+exam)
3. Screen tree
S1 Exams List ──tap row──▶ S2 Exam Detail
│ ▲ (FAB "New exam" → S8 Create Exam form)
│ │
│ └──(edit)──▶ S8 Create/Edit Exam form (S8a edit: same form, PATCH)
│
S2 Exam Detail
├── tap slot ──▶ S3 Marks Entry
├── FAB "Add subject" ──▶ S4 Add Subject Slot form
├── action "Publish" ──▶ S5 Publish confirm dialog
└── action "Edit"/"Delete" ──▶ S8a / confirm
S6 Student Results ──tap exam row──▶ S7 Report Card
4. Route map (forward-looking Flutter routes)
| Route | Screen | Guard (target (planned)) |
|---|---|---|
/exams | S1 Exams List | exam.read |
/exams/:examId | S2 Exam Detail | exam.read |
/exams/:examId/slots/:slotId/marks | S3 Marks Entry | exam.mark |
/exams/:examId/slots/new | S4 Add Subject Slot | exam.update |
/exams/new · /exams/:examId/edit | S8 / S8a Create/Edit Exam | exam.create / exam.update |
/results | S6 Student Results (mine) | result.read |
/results/:examId/report-card | S7 Report Card (mine) | result.read |
Deep links studylyon://exams/:id, studylyon://exams/:id/slots/:slotId/marks,
studylyon://results/:examId — (forward-looking), no notification payload defines
them yet (only results-published in-app job exists, event-queue-map.ts:27).
5. Navigation rules
- Depth: list → detail → slot marks = 3 levels, the max allowed
(
00-shared/05 §4); marks entry is a page, not a deeper sheet. - Return semantics: marks screen back = save-in-progress banner if unsaved edits; publish dialog is modal and non-dismissable on confirm (progress state).
- Tabs: no module-local tabs on the list (single dimension: exams). The detail uses one segmented control: Schedule (slots) / Coverage (marks status per slot — computed from slot results counts).
- Cross-links: slot rows link to the Class (Students module roster) and Subject
(Academics module) — resolved names,
(forward-looking)module deps.
6. Content model (from schemas)
| Entity | Source | Key fields exposed |
|---|---|---|
| Exam | examination.schema.ts:9-36 | academicYearId, name, type (5-value enum), startDate, endDate, status (4-value enum), gradingSchemeId? |
| Subject slot | examination-subject.schema.ts:9-31 | examinationId, subjectId, classId, date, startTime/endTime (HH:mm strings), maximumMarks, passingMarks |
| Mark/Result | examination-result.schema.ts:9-25 | studentId, examinationSubjectId, marksObtained?, grade?, remarks?, publishedAt? |
| Report card | result.service.ts:8-26 | subjects[] (subjectId, subjectName=raw id, marksObtained, maximumMarks, grade?, remarks?), totalMarksObtained, totalMaximumMarks, percentage, overallGrade, generatedAt |
7. Naming conventions
- Plural nouns for lists (
examinations,subjects), singular for detail (examination,subject); status labels in sentence case (Unit test,Final exam); slot label pattern:Subject · Class · date HH:mm–HH:mm.
8. Empty & edge IA states
- No exams: list empty state + "New exam" CTA (create-first design).
- Exam with no slots: detail shows empty schedule + "Add subject" CTA; report
card from such an exam → server 404 "No subjects found for this examination."
(
result.service.ts:51-53) — client shows "No subjects scheduled yet". - Slot with no marks: marks screen empty state (roster unloaded).
- Deleted/cross-tenant: 404 → auto-return to list with snackbar
(
examination.service.ts:63, 87, 101, 144).
05 — Screen Inventory (Exams Module)
Every client screen for the Exams module. Screens S1–S8 derive from implemented endpoints; "state" columns list the states each screen must handle (see 06_Screen_Specifications.md for detail). Tokens referenced:
Sp(00-shared/02 §3),Ty(§2),El(§5),R(§4),Mo(§6). Analytics events are(proposed)per 00-shared/10_QA_Baseline.md §8.
| ID | Screen | Purpose | Route | Primary widgets | States | Permissions (target (planned)) | A11y focus | Animations | Analytics (proposed) |
|---|---|---|---|---|---|---|---|---|---|
| S1 | Exams List | Browse all exams of the tenant (paginated); entry point to planning & publish | /exams | AppBar, RefreshIndicator, ListView.builder of ExamCard, AppFAB.extended "New exam", AppFilterChip row (status/type), AppPaginationBar | Initial/loading (skeleton), success (list), empty (no exams), error (5xx + requestId), offline (banner + cache), pagination (loading-more, last-page) | exam.read | Screen reader reads status badges (never color-only), focus order filter→list→FAB, touch target ≥48 dp | Fade-in rows Mo.fast; pull-to-refresh spinner; FAB hero transition | exam.list_view, exam.row_tap, exam.create_start |
| S2 | Exam Detail | Exam header + schedule (subject slots) + coverage + actions (edit/delete/add subject/publish) | /exams/:examId | AppBar + AppMenu, header card (ExamHeaderCard), segmented control Schedule/Coverage, slot ListView (ExamSubjectCard), action bar FilledButton "Add subject", PublishButton | Loading, success, empty (no slots), error 404 (exam removed), offline (cached header + stale slots), publish-in-progress | exam.read (actions: exam.update/exam.publish/exam.delete) | Status changes announced via Semantics(liveRegion); publish button labeled with exam name | Header fade/slide Mo.base; segmented control Mo.fast; publish progress spinner; slot list stagger Mo.fast | exam.detail_view, exam.publish_tap |
| S3 | Marks Entry | Enter/correct marks for one subject slot (class roster) | /exams/:examId/slots/:slotId/marks | SlotHeaderCard (subject, class, date/time, max/pass marks), roster ListView of MarksRow, MarksTextField per row, coverage bar (MarksSummaryBar), save-per-row | Loading (roster+marks), success, empty (roster empty), error 404 (slot removed → pop to detail), per-row saving, per-row failed, offline queue pending | exam.mark | Numeric keyboard friendly, per-row semantics label "{student}, {marks}/{max}", error announcements | Row save: check animation Mo.fast; failed row shake Mo.shake; coverage bar animates width | marks_entry_open, marks_saved, marks_row_failed, marks_coverage |
| S4 | Add Subject Slot | Add one class+subject+date+time+marks slot to an exam | /exams/:examId/slots/new | Form page: AppDropdown (subject, class), AppDatePicker, two AppTextField time inputs, two numeric fields (max/pass marks) | Form validation (inline errors), submitting (spinner), success → pop, conflict warning (overlap detected) | exam.update | Form fields labeled, error summaries announced, date picker a11y | Submit button spinner; error inline slide Mo.fast | exam_slot_add_start, exam_slot_add_submit, exam_slot_add_conflict_warned |
| S5 | Publish Confirm (dialog) | Final gate before POST /examinations/:id/publish; shows coverage summary | overlay on /exams/:examId | AppDialog (warning icon, coverage recap, FilledButton "Publish" / TextButton "Cancel") | Initial (summary), submitting (spinner, non-dismissable), success → pop + badge update, error (404/5xx) | exam.publish | Dialog focus trap, Escape cancels, confirm requires explicit tap | Dialog scale-in Mo.base; success check Mo.fast | exam_publish_confirm, exam_publish_success, exam_publish_failure |
| S6 | Student Results (mine) | Read-only marks list for a student | /results | AppBar, list of ResultRowCard grouped by exam, RefreshIndicator | Loading, success, empty ("No results yet"), error, offline | result.read | Marks read as {subject} {marks}/{max} | Row stagger Mo.fast | results_view |
| S7 | Report Card | Per-student, per-exam aggregate: subject table, totals, percentage, overall grade | /results/:examId/report-card | ReportCardHeader (exam, generatedAt), subject table (ReportCardTable), totals card, GradeBadge (overall), remarks column | Loading, success, empty (404 "No subjects scheduled"), error | result.read | Table header announced; grade badge label includes letter + threshold | Table rows fade Mo.fast; grade badge pop Mo.base | report_card_view, report_card_share |
| S8 / S8a | Create / Edit Exam | Form to create (POST /examinations) or edit (PATCH /examinations/:id) | /exams/new · /exams/:examId/edit | Form: AppDropdown (academic year, type), AppTextField name, two AppDatePicker; edit mode adds status read-only chip | Validation, submitting, success → pop to detail, error 400 field / 404 (edit deleted exam) | exam.create / exam.update | Same as S4 | Same as S4 | exam_form_submit, exam_form_error |
Cross-screen notes
- Shared empty/loading/error widgets:
AppSkeleton,AppEmptyState,AppErrorState,AppOfflineBannerfrom 00-shared/03 — no module-specific rebuilds. - Status badges (S1/S2):
draft,active,completed,published(examination.schema.ts:28-33) — one sharedExamStatusBadgecomponent (see 07). - Type chips (S1/S2/S8):
midterm,final,unit_test,quarterly,other(examination.schema.ts:15-20). - Published = terminal in UI (OQ-5): after
published, S2 hides Publish, Edit (status), Delete actions; S3 allows reads but marks edits are flagged. - Offline: S1/S2/S6/S7 read from last-good cache; S3 queues mark writes (module offline queue, 13_State_Management.md §4); S4/S8/S5 are online-only.
- Deep links
studylyon://exams/:idetc. —(forward-looking).
06 — Screen Specifications (Exams Module)
- 1. Exams List —
/exams(S1) - 2. Exam Detail —
/exams/:examId(S2) - 3. Marks Entry —
/exams/:examId/slots/:slotId/marks(S3) - 4. Add Subject Slot —
/exams/:examId/slots/new(S4) - 5. Publish Confirm — dialog on S2 (S5)
- 6. Student Results —
/results(S6) - 7. Report Card —
/results/:examId/report-card(S7) - 8. Create / Edit Exam —
/exams/new·/exams/:examId/edit(S8 / S8a)
Production-grade specs for each Exams screen: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens:
Sp= 00-shared/02 §3,Ty= typography §2,El= elevation §5,R= radius §4,Mo= motion §6. All components reference 00-shared/03 and 07 (module). Every data binding cites the exact schema/service field.
1. Exams List — /exams (S1)
Chrome: AppBar "Exams" (titleLarge, El-e1); AppFilterChip row below
(status: draft|active|completed|published + type: midterm|final|unit_test|quarterly| other — enums examination.schema.ts:15-20, 28-33); body RefreshIndicator +
ListView.builder; AppFAB.extended "New exam" (Icons.add, unique heroTag).
Bottom: AppPaginationBar (page/limit, 00-shared/07 §5).
Row — ExamCard:
[type chip] [status badge: draft/active/completed/published]
Name titleMedium, 2 lines max
Term 2026-27 · Midterm bodySmall onSurfaceVariant (academicYearId resolved)
15 Aug – 20 Aug · in 5d bodyMedium; startDate/endDate (ISO) → local dates
Slots 5 · Marks 120/240 bodySmall (derived: slots + marked/total)
Data: GET /examinations?page&limit (examination.controller.ts:30-32);
fields name, type, startDate, endDate, status, academicYearId
(examination.schema.ts:9-36). Sorting: server ignores sort/q (OQ-7) —
client sorts by startDate asc locally, filters chips client-side.
States:
- Loading:
AppSkeleton(list)6 rows. - Success: cards; pagination meta from
buildPaginationMeta(meta.page/limit/ totalItems/totalPages/hasNext/hasPrevious,pagination-query.dto.ts:41-55). - Empty:
AppEmptyStateiconassignment, "No exams yet" + subtitle "Plan your first exam for this academic year" + FAB remains; if empty and past academic year → "No exams in this term". - Error:
AppErrorState; 5xx showsrequestId; retry re-fetches. - Offline:
AppOfflineBanner+ cached list (TTL 24 h reference data,00-shared/06 §3.3). - Pagination: infinite-scroll on scroll end (guard
hasNext); "Loading more" footer row; end-of-list footer whenhasNext: false.
A11y: card = Semantics(button) "{name}, {type}, status {status}, starts {date}";
badge read appended; focus order: filter chips → list → FAB; SwitchAccessible.
Dark/light: token-only (00-shared/02); status badge published =
tertiaryContainer/tertiary-tinted container with icon verified + label (never
color-only, 00-shared/09). Tablet/desktop (≥840 dp): two-pane master–detail
(list 360–400 dp + S2); N shortcut = new exam; hover tint on cards.
Motion: rows fade-in Mo.fast (stagger 30 ms); FAB hero to S8.
2. Exam Detail — /exams/:examId (S2)
Chrome: AppBar title = exam name, trailing AppMenu (Edit / Delete — hidden
when status: 'published', OQ-5). Body RefreshIndicator + CustomScrollView.
Header card (surfaceContainerLow, r-lg, e-1)
name headlineMedium · type chip · status badge
AppInfoRow: Academic year (resolved) · Term dates (startDate – endDate)
AppInfoRow: Grading scheme (gradingSchemeId? — hidden when absent,
examination.schema.ts:35-36)
Segmented control: [Schedule] [Coverage]
Schedule tab: ExamSubjectCard list (per slot, see 07)
Coverage tab: per-slot CoverageTile: subject, class, marked/total, progress bar,
max/pass marks
Action bar (bottom, pinned):
[OutlinedButton "Add subject"] (exam.update)
[FilledButton "Publish"] (exam.publish; hidden when published)
Data: GET /examinations/:id (examination.controller.ts:33-35 →
examination.service.ts:61-65); slots GET /examinations/:id/subjects
(examination.controller.ts:51-53 → examination.service.ts:133-137); coverage
per slot GET /results/exam-subject/:examSubjectId (result.controller.ts:21-25).
States:
- Loading: header skeleton + 3 slot skeletons.
- Empty (no slots):
AppEmptyState"No subjects scheduled" + "Add subject" CTA; report-card entry on such exam → server 404 (result.service.ts:51-53). - Error 404 ("Examination not found.",
examination.service.ts:63): full-screenAppErrorState"This exam was removed" + back; auto-pop if opened via stale list. - Publish in progress: action bar button → spinner, dialog (S5) shows progress;
on success badge flips to
published(server response-driven, never optimistic). - Offline: cached header (last-good) + "stale" chip on slots (TTL 5 min); actions disabled (online-only).
- Disabled: when
published→ Publish hidden, menu Edit/Delete hidden, Add subject hidden (immutability contract, OQ-5); whencompleted→ Publish still allowed (server allows; no guard, OQ-1).
A11y: status badge changes announced via Semantics(liveRegion: true);
segmented control = radio semantics; publish button label includes exam name.
Responsive: tablet adds a right rail: selected slot's coverage panel.
Motion: header fade/slide Mo.base (200 ms); segmented switch Mo.fast;
slot list stagger Mo.fast.
3. Marks Entry — /exams/:examId/slots/:slotId/marks (S3)
Chrome: AppBar "Marks" + slot context in titleSmall; body
SlotHeaderCard:
Subject name · Class name titleMedium
Date 15 Aug · 09:00–12:00 bodyMedium (date + startTime/endTime strings,
examination-subject.schema.ts:18-25)
Maximum 100 · Pass 33 bodySmall; "Passing" styled tertiary when
passingMarks > maximumMarks (invalid, OQ-6)
Then MarksSummaryBar: marked/total · save-state (saved|queued|failed) with
animated progress width. Roster ListView.builder of MarksRow
(from Students module by slot classId; marks from
GET /results/exam-subject/:examSubjectId).
Row:
[avatar initials] Student name bodyMedium
roll/admission no bodySmall onSurfaceVariant
[ MarksTextField ] [GradeChip optional] [save status icon]
MarksTextField: numeric (digits only,FormattedNumberInput), max length = digits ofmaximumMarks;@Min(0)mirrors DTO (examination-subject.dto.ts:51-55).- Row-level save: submit on focus-loss or explicit save tap (see
10_Interaction_Specification.md §3for cadence).
Save flow: POST /results/exam-subject/:examSubjectId/marks
(result.controller.ts:26-31 → examination.service.ts:139-195). Upsert: existing
(studentId, examinationSubjectId) doc updated in place (examination.service.ts:147-175);
unique index {tenantId, studentId, examinationSubjectId} (examination-result.schema.ts:31-33).
Payload {studentId, marksObtained, grade?, remarks?} (EnterMarksDto,
examination-subject.dto.ts:47-66).
States:
- Loading: roster skeleton rows.
- Roster empty:
AppEmptyState"No students in this class" (slotclassIdhas no students). - Slot gone (404 "Exam subject not found.",
examination.service.ts:144): banner + auto-pop to exam detail (refresh slots). - Per-row saving: inline spinner in save-status slot; optimistic green check on
success (
Mo.fastcheck animation); failure → row shakeMo.shake, "Retry" affordance; error toast for 404 "Marks cannot exceed maximum." (examination.service.ts:145-146) → open marks dialog clamped tomaximumMarks. - Offline: row flagged
queued(dashed border + cloud icon); offline queue flush on reconnect (13_State_Management.md §4); coverage bar shows queued count. - Disabled: after exam
published, editing allowed server-side (OQ-5) but rows show "published" chip + confirm-before-edit sheet; publish-in-flight disables all fields.
A11y: row semantics "{student name}, {marks} of {maximum}"; keyboard: Tab
between fields, Enter = save current row + focus next; Semantics(liveRegion) on
coverage bar. Dark/light: token-only; failed row errorContainer tint.
Responsive: tablet shows a right-side detail sheet (remarks/grade editor) instead
of inline expansion. Motion: save check Mo.fast, error shake Mo.shake
(00-shared/08), coverage bar width 200 ms ease.
4. Add Subject Slot — /exams/:examId/slots/new (S4)
Chrome: full-screen form page (> 3 fields rule, 00-shared/05 §5); AppBar
"Add subject" + Save TextButton; body form list Sp.16 gaps,
resizeToAvoidBottomInset: true. Field order (mirrors CreateExaminationSubjectDto,
examination-subject.dto.ts:11-45):
1 subject AppDropdown (Academics subjects; required; @IsMongoId)
2 class AppDropdown (Academics classes; required; @IsMongoId)
3 date AppDatePicker (required; @IsDateString; client min = exam startDate,
max = exam endDate)
4 startTime AppTextField "Start time" (HH:mm, keyboard=time; example '09:00')
5 endTime AppTextField "End time" (HH:mm; client rule endTime > startTime)
6 maxMarks AppTextField number (required; @Min(1); @IsNumber)
7 passMarks AppTextField number (required; @Min(1); client rule ≤ maxMarks)
Client pre-validation (server lacks — OQ-2/OQ-3/OQ-6):
- Overlap: same date + time window touching another existing slot of this exam →
inline warning +
AppDialog"Overlaps with Subject X 09:00–12:00 — add anyway?" (server has no conflict logic; confirm proceeds). - Duplicate: subject already added to this exam → warning "Subject already scheduled (date 15 Aug)" — proceed allowed (no server dedupe, OQ-3).
passingMarks > maximumMarks→ inline error before submit.- Date outside exam window → inline error.
Submit: POST /examinations/:id/subjects (examination.controller.ts:45-50 →
examination.service.ts:112-131) with examinationId taken from route (never typed).
States: field errors (400 VALIDATION_ERROR → inline, focus first invalid);
submitting spinner on Save; success → pop + new slot visible; 404 exam missing →
pop with snackbar. A11y/other: as S8 (§8). Motion: inline error slide
Mo.fast.
5. Publish Confirm — dialog on S2 (S5)
Chrome: AppDialog (00-shared/03), width ≤ 480 dp:
Icon warning_amber (large, tertiary)
"Publish results?"
"Results for {N} subjects will be released to students and parents.
Unpublished marks stay hidden." ← server truth: publish stamps publishedAt
(examination-result.repository.ts:42-51)
Coverage recap: 3 slots · 118/120 marks entered (from slot result counts)
[Cancel TextButton] [Publish FilledButton (tertiary)]
States: initial; submitting → button spinner, dialog non-dismissable, barrierDismissible: false; success → close, badge published, snackbar "Results published";
error 404/5xx → reopen dialog with error line + requestId, Publish re-enabled.
Note: server allows re-publish and post-publish edits (OQ-5) — the client
enforces immutability; dialog is the single confirm gate. A11y: focus trap,
Escape = cancel, confirm label "Publish". Motion: scale-in Mo.base, success
check Mo.fast.
6. Student Results — /results (S6)
Chrome: AppBar "My Results" (+ parent: "Name's Results"); body
RefreshIndicator + grouped list by exam (GET /results/student/:studentId,
result.controller.ts:16-20 → result.service.ts:36-38).
Row — ResultRowCard: exam name (resolved via exam id? — payload only carries
examinationSubjectId; client groups by slot's exam; subject names resolved
client-side from slot ids — OQ-9), subjectName · marksObtained/maximumMarks · grade chip, publishedAt formatted ("Published 15 Aug"), remarks preview.
States: loading; empty "No results yet — check after your next exam"; error;
offline banner + cache (TTL 5 min). Note: the endpoint returns unpaginated
arrays — client slices by exam client-side (OQ-7 pattern).
A11y: each row one Semantics label "{subject}, {marks} of {max}, grade {g}".
Responsive: tablet = table layout (columns subject/marks/grade/remarks).
Motion: row stagger Mo.fast.
7. Report Card — /results/:examId/report-card (S7)
Chrome: AppBar "Report Card" + share action (Icons.share → image share,
(proposed)). Body card-stack on surfaceContainerLow:
Header: exam name · generatedAt (ISO → "Generated 15 Aug 14:30")
Overall card (primaryContainer, r-lg):
percentage displaySmall mono tabular ("82.5%")
overallGrade GradeBadge "A" + threshold caption ("80–89%")
totals row: totalMarksObtained / totalMaximumMarks
Subject table (ReportCardTable, tablet=real columns / phone=rows):
subjectName (resolved, OQ-9) · marksObtained/maximumMarks ·
grade (client-supplied, may be empty) · remarks (may be empty)
Data: GET /results/report-card/:studentId/:examId (result.controller.ts:32-37
→ result.service.ts:46-98): subjects[], totalMarksObtained,
totalMaximumMarks, percentage (2 dp), overallGrade (A+/A/B+/B/C/D/F at
90/80/70/60/50/40 — result.service.ts:130-138), generatedAt.
States: loading skeleton; empty/404 ("No subjects found for this examination.",
result.service.ts:51-53) → AppEmptyState "No subjects scheduled for this exam";
error; offline (cached card, publishedAt-stamped cards are cacheable 24 h).
Edge rendering: subject with no result → row shows 0/max + muted "not marked"
hint (payload cannot distinguish 0 vs absent — result.service.ts:70); empty
grade → "—"; remarks overflow → 2-line clamp + expand. If all subjects are
unmarked → percentage 0, grade F — show explanatory note, not an error.
A11y: table headers announced; GradeBadge label "A, 80 to 89 percent".
Responsive: phone = card rows, ≥840 dp = real table; landscape keeps table.
Motion: rows fade Mo.fast; badge pop Mo.base.
8. Create / Edit Exam — /exams/new · /exams/:examId/edit (S8 / S8a)
Chrome: full-screen form; AppBar "New exam" / "Edit exam" + Save; body Sp.16
gaps. Fields mirror CreateExaminationDto (examination.dto.ts:4-26) /
UpdateExaminationDto (examination.dto.ts:28-53):
1 academicYear AppDropdown (Academic years; create only; required; @IsMongoId)
2 name AppTextField (required; @IsString; maxLength 100 client)
3 type AppDropdown midterm|final|unit_test|quarterly|other
(required; schema enum examination.schema.ts:15-20; DTO is IsString
only — client constrains, OQ note)
4 startDate AppDatePicker (required; @IsDateString)
5 endDate AppDatePicker (required; client rule endDate ≥ startDate — OQ-6)
6 status (edit) read-only chip (draft|active|completed|published) — client never
sends status on create; edit sends only explicit transitions (OQ-1)
Submit: create → POST /examinations → 201 doc, status: 'draft' forced
(examination.service.ts:48); edit → PATCH /examinations/:id partial
(examination.service.ts:81-97), $set only sent fields, version +1
(base.repository.ts:57-66). Edit prefill from GET /examinations/:id.
States: field validation; submitting spinner; success → pop to detail +
snackbar; 400 field errors inline; 404 (edit of deleted exam,
examination.service.ts:87) → pop + "Exam no longer exists"; status edit rule:
chips for draft → active only (free-string status risk, OQ-5); after published
the edit route is unreachable from UI.
A11y/keyboard: labeled fields, error summaries announced, autofocus name;
submit via keyboard done. Responsive: single column ≤ 600 dp, two-column grid
≥ 840 dp. Motion: inline error slide Mo.fast.
07 — Component Library (Exams Module)
- 1. ExamStatusBadge
- 2. ExamTypeChip
- 3. ExamCard (S1 list row)
- 4. ExamSubjectCard (slot row, S2)
- 5. MarksRow (S3 roster row)
- 6. MarksSummaryBar (S3 coverage)
- 7. GradeChip
- 8. CoverageTile (S2 coverage tab)
- 9. PublishButton + PublishDialog (S2/S5)
- 10. SlotHeaderCard (S3 top)
- 11. ResultRowCard (S6)
- 12. ReportCardTable (S7)
- 13. GradeBadge (S7 overall)
- Reuse summary
Module-specific components on top of 00-shared/03_Component_Library.md (primitives like
AppButton,AppTextField,AppDialog,AppDropdown,AppDatePicker,AppEmptyState,AppSkeletonare reused, never re-built). Each component lists variants, states, and token mapping. Tokens:Sp= 00-shared/02 §3,Ty§2,El§5,R§4,Mo§6, color roles §1.
1. ExamStatusBadge
| Purpose | Render the exam lifecycle state: draft, active, completed, published (examination.schema.ts:28-33) |
| Variants | draft → neutral (surfaceVariant/onSurfaceVariant); active → primary; completed → secondary; published → tertiary + Icons.verified |
| States | icon + label always (never color-only, 00-shared/09); loading → skeleton chip |
| Tokens | labelSmall (Ty), radius r-full, padding Sp.4/Sp.8, gap Sp.4 |
| A11y | Semantics(label: 'Status: published') |
2. ExamTypeChip
| Purpose | Show exam type: midterm, final, unit_test, quarterly, other (examination.schema.ts:15-20); labels: "Midterm", "Final", "Unit test", "Quarterly", "Other" |
| Variants | selected (filter mode, primaryContainer) / static (informational, surfaceContainerHighest) |
| Tokens | labelMedium, r-full, Sp.4/Sp.10 |
3. ExamCard (S1 list row)
| Data | name, type, startDate, endDate, status, academicYearId (examination.schema.ts:9-36); derived: slots count + marked/total (from coverage queries) |
| Layout | 2-line header + meta row + footer row; inkWell whole-card tap → S2 |
| States | default / pressed (stateLayer primary 8%) / hover (desktop tint) / disabled (not used in list) |
| Tokens | surfaceContainerLow, r-lg, e-1, padding Sp.16, gaps Sp.8/Sp.12 |
| A11y | Semantics(button) with full sentence label (§06 §1) |
4. ExamSubjectCard (slot row, S2)
| Data | subjectId, classId, date, startTime, endTime, maximumMarks, passingMarks (examination-subject.schema.ts:9-31); coverage marked/total |
| Layout | [subject icon] Subject · Class / date HH:mm–HH:mm / Max 100 · Pass 33 / progress (coverage) / chevron |
| Variants | with-coverage (progress bar from result counts) / without (slots without marks loaded); selected (tablet rail) |
| States | tap → S3; long-press → "Open marks" context menu (tablet) |
| Tokens | surfaceContainer, r-md, padding Sp.12/Sp.16; progress 4 dp primary/tertiary when full |
| A11y | Semantics(button, label: 'Subject, Class, date, time, max marks, N of M marked') |
5. MarksRow (S3 roster row)
| Data | student (roster, resolved from classId via Students module) + result marksObtained, grade?, remarks?, publishedAt? (examination-result.schema.ts:15-25) |
| Layout | avatar initials · name/meta · MarksTextField (digits, max digits of maximumMarks) · optional grade chip · save-state icon |
| States | unsaved (editing, outline primary) / saving (inline spinner) / saved (check tertiary, Mo.fast) / failed (shake Mo.shake, error icon, "Retry") / queued-offline (dashed border, cloud icon) / published (lock chip "published" — publishedAt set) |
| Tokens | row surfaceContainerLow, gap Sp.12; field width 96 dp, labelLarge mono tabular |
| A11y | row semantics label with marks vs max; inline error announced |
6. MarksSummaryBar (S3 coverage)
| Data | marked/total + queued/failed counts (cubit-derived, 13_State_Management.md) |
| Layout | label "118 of 120 marked" + animated bar; below: "2 offline · 1 failed — Retry" line |
| States | idle / saving / offline (dashed overlay) / complete (100%, tertiary) |
| Tokens | height 8 dp bar, r-full, primary; labelMedium; Semantics(liveRegion) |
7. GradeChip
| Data | grade? free text from EnterMarksDto (examination-subject.dto.ts:57-60) — client never computes per-subject grades (OQ-9) |
| Variants | filled (grade present, secondaryContainer) / absent (tap → grade sheet) |
| Tokens | labelMedium, r-full, padding Sp.4/Sp.8 |
8. CoverageTile (S2 coverage tab)
| Data | slot + marked/total via GET /results/exam-subject/:id; max/pass marks |
| Layout | subject · class · bar · "42/45" · "Max 100 · Pass 33" |
| States | none / partial / complete / no-marks (0 of N) |
| Tokens | surfaceContainer, r-md, padding Sp.12 |
9. PublishButton + PublishDialog (S2/S5)
| PublishButton | FilledButton.tonal tertiary, label "Publish results", icon campaign; states: enabled (status ≠ published) / disabled (published — hidden instead) / submitting (spinner) |
| PublishDialog | AppDialog wrapper (§06 §5): warning icon, coverage recap list, Cancel / Publish actions, non-dismissable while submitting |
| Tokens | dialog width ≤ 480 dp, r-lg, El-e3 |
10. SlotHeaderCard (S3 top)
| Data | subject/class names, date, startTime–endTime (HH:mm strings, examination-subject.schema.ts:18-25), maximumMarks, passingMarks |
| Warning state | passingMarks > maximumMarks → tertiary banner "Pass marks exceed max marks" (invalid config, OQ-6) |
| Tokens | surfaceContainerLow, r-lg, e-1, padding Sp.16 |
11. ResultRowCard (S6)
| Data | result doc (marksObtained, grade?, remarks?, publishedAt?, examination-result.schema.ts:15-25); subject/exam resolved client-side (OQ-9) |
| Layout | subject · marks/max · grade chip · published date · remarks preview (2-line clamp) |
| Tokens | surfaceContainerLow, r-md, padding Sp.12/Sp.16 |
12. ReportCardTable (S7)
| Data | subjects[], totalMarksObtained, totalMaximumMarks, percentage, overallGrade, generatedAt (result.service.ts:8-26, 88-97) |
| Layout | phone: stacked rows; ≥840 dp: real table (Subject / Marks / Grade / Remarks) + totals row + overall card |
| States | row missing mark → "0" + muted "not marked" (payload can't distinguish, result.service.ts:70); empty grade → "—" |
| Tokens | header labelLarge onSurfaceVariant; numbers mono tabular; r-lg card wrap |
13. GradeBadge (S7 overall)
| Data | overallGrade letter (A+/A/B+/B/C/D/F — result.service.ts:130-138) |
| Tokens | displaySmall letter, r-lg padding Sp.8/Sp.16, primaryContainer; caption labelMedium "80–89%" |
| A11y | Semantics(label: 'Grade A, 80 to 89 percent') |
Reuse summary
| Need | Component source |
|---|---|
| Buttons, text fields, dialogs, dropdowns, date pickers, skeletons, empty/error/offline states | 00-shared/03 primitives |
| Motion, haptics | 00-shared/08_Interaction_&_Motion.md (Mo.fast/base/shake) |
| Accessibility baseline | 00-shared/09_Accessibility_Baseline.md |
| Forms & validation patterns | 08_Form_Specifications.md (module) |
08 — Form Specifications (Exams Module)
- 1. Create Exam Form (S8)
- 2. Edit Exam Form (S8a)
- 3. Add Subject Slot Form (S4)
- 4. Enter Marks Form (S3, per row)
- 5. Validation error mapping (all forms)
Every form field, field-by-field: label, widget, keyboard, validation, errors, defaults. All validations mirror the DTO decorators exactly — anything the server does not validate is marked client-only (see 01_Product_Overview.md §10 OQ-2/OQ-3/OQ-6). DTO sources:
examination.dto.ts,examination-subject.dto.ts. Error codes per 00-shared/07 §3.
1. Create Exam Form (S8)
Submit: POST /api/v1/examinations (examination.controller.ts:27-29);
payload = CreateExaminationDto (examination.dto.ts:4-26). Server forces
status: 'draft' (examination.service.ts:48).
| # | Field | Label | Widget | Keyboard | Server validation | Client-only validation | Default | Error |
|---|---|---|---|---|---|---|---|---|
| 1 | academicYearId | Academic year | AppDropdown | — | @IsMongoId (examination.dto.ts:6-7) | — | none (required) | "Select the academic year" / 400 VALIDATION_ERROR |
| 2 | name | Exam name | AppTextField | text (autocorrect off) | @IsString (examination.dto.ts:10-11) | required, ≤100 chars, trim | — | "Enter the exam name" |
| 3 | type | Exam type | AppDropdown | — | @IsString (no enum decorator, examination.dto.ts:13-17; schema enum ['midterm','final','unit_test','quarterly','other'], examination.schema.ts:15-20 — invalid values fail at save() as a validator error) | picker constrained to the 5 values | — | "Choose a type" |
| 4 | startDate | Start date | AppDatePicker | — | @IsDateString (examination.dto.ts:19-21) | client-only: ≤ endDate (OQ-6) | — | "Start date must be on or before end date" |
| 5 | endDate | End date | AppDatePicker | — | @IsDateString (examination.dto.ts:23-25) | client-only: ≥ startDate (OQ-6) | — | "End date must be on or after start date" |
Submit behavior: disabled until required fields valid; spinner on submit; on 400
VALIDATION_ERROR → inline field errors (focus first invalid); success → 201 doc →
pop to S2 with snackbar "Exam created".
Offline: form is online-only; unsaved draft persisted to local draft box on
back (optional, (proposed)).
2. Edit Exam Form (S8a)
Submit: PATCH /api/v1/examinations/:id (examination.controller.ts:36-41);
payload = UpdateExaminationDto (examination.dto.ts:28-53) — all optional
(@IsOptional), only changed fields sent ($set, base.repository.ts:57-66).
Prefill from GET /examinations/:id; academicYearId locked (not in DTO).
| # | Field | Server validation | Client rules |
|---|---|---|---|
| 1 | name? | @IsOptional @IsString | as §1 |
| 2 | type? | @IsOptional @IsString | picker only |
| 3 | startDate? / endDate? | @IsOptional @IsDateString | window rule as §1 |
| 4 | status? | @IsOptional @IsString — free string, schema enum NOT enforced on PATCH (updateById without runValidators, base.repository.ts:57-66; OQ-5) | client only ever sends an explicit draft → active transition (OQ-1); never published; once published, edit unreachable (immutability contract) |
Errors: 400 field errors; 404 "Examination not found." (examination.service.ts:87)
→ pop + snackbar "This exam no longer exists."
3. Add Subject Slot Form (S4)
Submit: POST /api/v1/examinations/:id/subjects (examination.controller.ts:45-50;
examinationId injected from route, examination.service.ts:49); payload =
CreateExaminationSubjectDto (examination-subject.dto.ts:11-45).
| # | Field | Label | Widget | Keyboard | Server validation | Client-only validation | Default | Error |
|---|---|---|---|---|---|---|---|---|
| 1 | subjectId | Subject | AppDropdown | — | @IsMongoId (examination-subject.dto.ts:16-18) | client-only: warn "already scheduled" if duplicate (no server dedupe — OQ-3) | — | "Select a subject" |
| 2 | classId | Class | AppDropdown | — | @IsMongoId (examination-subject.dto.ts:20-22) | — | — | "Select a class" |
| 3 | date | Exam date | AppDatePicker | — | @IsDateString (examination-subject.dto.ts:24-26) | client-only: within exam startDate..endDate; warn overlap with existing slot times (OQ-2) | — | "Date must be within the exam window" |
| 4 | startTime | Start time | AppTextField | TextInputType.datetime (HH:mm) | @IsString (examination-subject.dto.ts:28-30; example '09:00') | required, HH:mm pattern | 09:00 | "Enter a valid time (HH:mm)" |
| 5 | endTime | End time | AppTextField | datetime | @IsString (examination-subject.dto.ts:31-34) | client-only: endTime > startTime (OQ-6) | 12:00 | "End time must be after start time" |
| 6 | maximumMarks | Maximum marks | AppTextField | number (digits) | @IsNumber @Min(1) (examination-subject.dto.ts:36-39) | client-only: ≥ passingMarks; sane cap 1000 | 100 | "Maximum marks must be at least 1" |
| 7 | passingMarks | Passing marks | AppTextField | number | @IsNumber @Min(1) (examination-subject.dto.ts:41-44) | client-only: ≤ maximumMarks (OQ-6) | 33 | "Passing marks cannot exceed maximum marks" |
Conflict dialog: on overlap/duplicate detection (fields 1–5), submit shows
AppDialog "Overlaps with Subject X 09:00–12:00 — add anyway?" (server won't
reject; OQ-2/OQ-3).
Errors: 400 VALIDATION_ERROR inline; 404 "Exam subject not found." — n/a here;
exam missing → 404 → pop.
4. Enter Marks Form (S3, per row)
Submit: POST /api/v1/results/exam-subject/:examSubjectId/marks
(result.controller.ts:26-31); payload = EnterMarksDto
(examination-subject.dto.ts:47-66). Upsert: an existing
(studentId, examinationSubjectId) doc is updated in place
(examination.service.ts:147-175; unique index examination-result.schema.ts:31-33).
| # | Field | Label | Widget | Keyboard | Server validation | Client-only validation | Default | Error |
|---|---|---|---|---|---|---|---|---|
| 1 | studentId | (hidden) | filled from roster row | — | @IsMongoId (examination-subject.dto.ts:49-51) | — | roster student | — |
| 2 | marksObtained | Marks | MarksTextField | number, digits only | @IsNumber @Min(0) (examination-subject.dto.ts:52-55) | critical: marksObtained ≤ maximumMarks of the slot — server rejects with 404 NotFoundException('Marks cannot exceed maximum.') (examination.service.ts:145-146), not 422 | — | inline "Marks cannot exceed {max}"; on server 404 → refresh slot max, re-enter |
| 3 | grade? | Grade | GradeChip → grade sheet (text) | text | @IsOptional @IsString (examination-subject.dto.ts:57-60) — free text, never computed server-side per subject (OQ-9) | optional; ≤10 chars | — | — |
| 4 | remarks? | Remarks | AppTextField multiline (2–3 lines) | text | @IsOptional @IsString (examination-subject.dto.ts:62-65) | optional; ≤500 chars | — | — |
Row submit cadence: save on focus-loss or explicit save tap; retry per failed
row; offline → row queued (see 10_Interaction_Specification.md §3,
13_State_Management.md §4). Marks blank → not submitted (no clear endpoint);
client offers "Clear" by submitting 0 with confirm ("0 is stored — server treats
missing marks as 0 in report cards", result.service.ts:70).
5. Validation error mapping (all forms)
| Server code | Client handling |
|---|---|
400 VALIDATION_ERROR | inline field errors (details[] per 00-shared/07 §3), focus first invalid |
| 401 / 403 | session flow / permission screen (00-shared/06 §3.6) |
404 RESOURCE_NOT_FOUND | resource-gone state (marks: "Marks cannot exceed maximum." is 404 — special-cased, examination.service.ts:146) |
409 DUPLICATE_RESOURCE | not expected today (no unique constraints on exams/slots, OQ-3) |
429 RATE_LIMITED | backoff, no auto-retry (00-shared/07 §4) |
| 5xx | generic + requestId, retry |
09 — User Behaviour (Exams Module)
- 1. Planning cadence (Coordinator)
- 2. Slot overlap avoidance (designed behaviour; server lacks it — OQ-2)
- 3. Marks entry — the efficiency loop (Teacher)
- 4. Error recovery behaviour
- 5. Publish behaviour (Principal / Coordinator)
- 6. Read-only consumption (Student / Parent)
- 7. Session & permission behaviour
- 8. Cross-session & multi-device
- 9. Accessibility behaviour (00-shared/09)
- 10. Instrumentation
(proposed)
Behavioural patterns observed, designed-for, and tested — derived from the server contract (
examination.service.ts,result.service.ts) and the shared behavioural baseline 00-shared/10_QA_Baseline.md /09_Accessibility_Baseline.md.
1. Planning cadence (Coordinator)
- Burst creation: coordinators plan an entire term in one sitting — the form
must support rapid sequential creation (after submit, "Create another" ghost
action; the list FAB is always reachable). Each subject slot is one POST
(
examination.controller.ts:45-50); a 5-slot exam = 5 requests — the client sequences them with per-request error isolation (one failure doesn't abort the rest, unlike the server's sequentialaddSubject— each call is independent). - Window discipline: coordinators set
startDate/endDatefirst; the slot date picker clamps to the window (mirrorsCreateExaminationDto,examination.dto.ts:19-25).
2. Slot overlap avoidance (designed behaviour; server lacks it — OQ-2)
- Before submitting a slot, the client compares
date + startTime/endTime(examination-subject.schema.ts:18-25) against already-loaded slots. Overlap → inline warning + confirm dialog. Users may still proceed (server won't reject), but the warning makes the coordinator's mental model explicit. - Duplicate subject in the same exam → "already scheduled (date …)" warning (no server dedupe — OQ-3).
3. Marks entry — the efficiency loop (Teacher)
- Numeric-first: the marks field is the only always-editable element per row;
Entersaves the current row and focuses the next (08_Form_Specifications.md §4). - Correct-in-place: re-tap a saved cell → edit → save → same upsert endpoint
(
examination.service.ts:147-175); no edit-mode toggle (server is idempotent). - Coverage awareness: the
MarksSummaryBaris the "done" signal; teachers leave at 100% and return to the detail Coverage tab later for stragglers. - Offline resilience: classroom Wi-Fi drops are expected; queued rows persist and flush on reconnect with idempotency keys (13_State_Management.md §4) — re-sent upserts converge safely.
4. Error recovery behaviour
| Trigger | Behaviour |
|---|---|
404 "Marks cannot exceed maximum." (examination.service.ts:145-146) | special-cased: not a deleted resource — refresh slot max, show inline error, re-enter |
404 "Exam subject not found." (examination.service.ts:144) | auto-pop to detail, refresh slots, snackbar |
404 "Examination not found." (examination.service.ts:63, 87, 101) | pop to list, snackbar, purge cached exam |
404 "No subjects found for this examination." (result.service.ts:51-53) | report card empty state, no retry loop |
| 5xx | banner + requestId, manual retry, last-good cache |
| 429 | backoff, no auto-retry, countdown |
5. Publish behaviour (Principal / Coordinator)
- Publish is deliberate: confirm dialog shows slot coverage recap (marked/total
per slot). Server publishes whatever exists (
examination.service.ts:203-220) — the recap is the client's guard, not the server's. - Never optimistic: the
publishedbadge flips only from the server response. - Post-publish: client enforces immutability (publish/edit/delete hidden) even though the server permits re-publish and PATCH (OQ-5) — users trust the UI's "published = locked" semantics.
6. Read-only consumption (Student / Parent)
- Results are checked after a "results published" notification
(planned)— the only routed job today isin-app/results-published(event-queue-map.ts:27); polling falls back toGET /results/student/:id. - Report card shares: screenshot share
(proposed)— no PDF endpoint exists (report-card is a JSON aggregate,result.service.ts:46-98). - A subject with no mark renders as
0with "not marked" (payload ambiguity,result.service.ts:70) — explicitly explained, never presented as a score of 0.
7. Session & permission behaviour
- Session expiry mid-marks-entry: preserved rows stay in the offline queue; after re-login, flush resumes (00-shared/06 §3.6).
- Permission drops (403): feature gating by role claim today (RBAC
(planned), OQ-4); on 403 the screen shows a read-only state with a "Contact admin" note — never a crash.
8. Cross-session & multi-device
- Exam list and detail are server-truth on pull-to-refresh; coverage numbers can
change when another teacher enters marks (multi-teacher same exam) — the marks
screen re-fetches on focus and on WS
results.published-style events(planned), 00-shared/07 §8. - Last-write-wins for marks (upsert) — two teachers editing the same row converge
silently; no conflict UI today (unique index only,
examination-result.schema.ts:31-33).
9. Accessibility behaviour (00-shared/09)
- Statuses/state conveyed by icon + label + text (never color-only): badges, coverage bar, save states, grade chips.
- Numeric fields announce
"{marks} of {max}"; failed rows announce the error. - Minimum touch targets 48 dp; focus order stable per screen (§06); reduced
motion honors
Moduration halving.
10. Instrumentation (proposed)
Listed per screen in 05_Screen_Inventory.md: exam.list_view, exam.row_tap,
exam.create_start, exam.detail_view, exam.publish_tap/success/failure,
marks_entry_open, marks_saved, marks_row_failed, marks_coverage,
exam_slot_add_*, results_view, report_card_view, report_card_share.
10 — Interaction Specification (Exams Module)
- 1. List → Detail (S1 → S2)
- 2. Exam Detail (S2)
- 3. Marks Entry (S3) — the core cadence
- 4. Add Subject Slot (S4)
- 5. Report Card (S7)
- 6. Keyboard & focus
- 7. Haptics summary
- 8. Motion budget
Precise micro-interactions for the Exams screens. Motion tokens (
Mo.fast,Mo.base,Mo.shake) and haptics per 00-shared/08_Interaction_&_Motion.md; state management per 13_State_Management.md; components per 07.
1. List → Detail (S1 → S2)
- Tap card: 120 ms pressed
stateLayer; on release navigate; row-to-header shared-element transition (exam name)Mo.base(200 ms, easeOutCubic); list fades backMo.fast. - Pull-to-refresh: re-fetch
GET /examinations(bypass cache); on 304/no-change, no animation; new rows stagger-inMo.fast(30 ms). - Pagination: auto-load next page at 300 px from bottom when
hasNext(pagination-query.dto.ts:46-54); footer spinner; failure → inline "Load more failed — Retry".
2. Exam Detail (S2)
- Segmented control Schedule/Coverage:
Mo.fast(120 ms) indicator slide; content cross-fade, no rebuild of the other tab (lazy). - Slot tap: chevron rotates 90°, navigation to S3 with slot header shared
element (
Mo.base). - Menu (Edit/Delete): bottom sheet (phone) / menu (desktop). Delete →
AppDialog"Delete this exam? Subjects and marks stay on the server." — soft delete, no cascade (examination.service.ts:99-110,base.repository.ts:68-74); confirm → spinner on action → success pop + snackbar "Exam deleted". - Publish (S5 dialog): opens with coverage recap; confirm =
FilledButton(tertiary). While submitting: barrier lock, button spinner, hapticmediumImpacton success; badge flips topublishedwithMo.fastpop; hapticerroron failure + dialog reopens with error line.
3. Marks Entry (S3) — the core cadence
- Enter = save + advance: typing digits into
MarksTextField, pressing Enter (or Tab-out) saves that row (POST /results/exam-subject/:id/marks) and focuses the next row's field. Saves are optimistic with server-confirm rollback (row shows local value immediately; on error → rollback + shake). - Save-state per row: pending (field outline primary) → saving (spinner 120 ms
min display to avoid flicker) → saved (check
Mo.fast, hapticlightImpact) → failed (shakeMo.shake300 ms, error icon, "Retry"). - Max-marks clamp: typing past
maximumMarksdigits is blocked; paste over-clamped values → inline error, field select-all (pre-validates the 404 server path,examination.service.ts:145-146). - Grade sheet: tap
GradeChip→ bottom sheet with common grades (A+, A, B+, B, C, D, F — matching report-card bands,result.service.ts:130-138) + free text; select → chip fills, save on row save. - Remarks: inline expansion (chevron) → 2–3 line field; saved with row.
- Offline: row → dashed outline + cloud icon;
MarksSummaryBarshows "2 offline · 1 failed"; reconnect → auto-flush (order FIFO, 13_State_Management.md §4); conflict (409) → per-row conflict sheet: preview server vs local, choose overwrite/discard. - Back navigation with unsaved rows: save-confirm sheet "Save 3 unsaved
marks?" [Save & exit / Discard / Stay];
publishedexam → confirm sheet before any edit (immutability contract, OQ-5). - Coverage bar: width animates 200 ms ease; at 100% turns
tertiary+ hapticlightImpact(milestone).
4. Add Subject Slot (S4)
- Field order matters: subject → class → date → times → marks; date picker clamps to exam window (08_Form_Specifications.md §3).
- Overlap detection on submit: animate a highlight ring around the conflicting slot (in a mini-schedule strip) while the confirm dialog appears — "Overlaps with Math 09:00–12:00 — add anyway?"
- Submit: spinner on
Save; success → pop with slot card slide-in on S2 (Mo.fast).
5. Report Card (S7)
- Share: action → render card to image (RepaintBoundary) → share sheet
(proposed); hapticselectionon share. - Rows: fade
Mo.faststagger; unmarked rows muted with "not marked" hint (payload ambiguity,result.service.ts:70). - Grade badge: pop-in
Mo.baseon load; announcement via live region.
6. Keyboard & focus
| Screen | Behaviour |
|---|---|
| S1/S2 | scroll navigation; N = new exam (desktop); P = publish (detail, desktop) |
| S3 | Tab cycles fields; Enter saves+advances; Esc clears current input; hardware-keyboard users get the full row cadence (§3) |
| S4/S8 | autofocus first field (name/subject); Enter submits when on last field; submit on keyboard done |
7. Haptics summary
| Action | Haptic |
|---|---|
| Row saved | lightImpact |
| Publish success | mediumImpact |
| Save failure / max violation | error (heavy) |
| Coverage 100% | lightImpact |
| Overlap warning shown | selection |
8. Motion budget
Per screen, total transition budget < 400 ms (list) / < 300 ms (forms); reduced-motion doubles durations off (00-shared/08 §5); no screen has infinite/looping animations except the publish progress spinner.
11 — Design System Mapping (Exams Module)
- 1. Semantic color mapping (status → color role)
- 2. Typography mapping (Ty, 00-shared/02 §2)
- 3. Spacing (Sp, 00-shared/02 §3)
- 4. Elevation (El, 00-shared/02 §5)
- 5. Radius (R, 00-shared/02 §4)
- 6. Iconography
- 7. Dark/light mapping
- 8. Component-to-shared mapping
- 9. Tokens never used
Maps every Exams surface to the shared design tokens (00-shared/02_Design_Tokens.md) and components (00-shared/03). Nothing in the module introduces new tokens; semantic color mapping is defined here.
1. Semantic color mapping (status → color role)
| Semantic | Color role (light / dark) | Used for |
|---|---|---|
Exam draft | surfaceVariant / onSurfaceVariant | ExamStatusBadge.draft, chip, filter selected-off |
Exam active | primary / primaryContainer | ExamStatusBadge.active, Coverage bar active state |
Exam completed | secondary / secondaryContainer | ExamStatusBadge.completed, GradeChip |
Exam published | tertiary / tertiaryContainer + Icons.verified | ExamStatusBadge.published, publish success state, publishedAt chips |
| Marks saved | tertiary check | row save-state icon |
| Marks failed / max violation | error / errorContainer | failed row, inline errors, MarksTextField error outline |
| Overlap / invalid config warning | tertiary banner (passingMarks > maximumMarks, OQ-6) | SlotHeaderCard warning, S4 conflict dialog |
| Grade absent / not marked | onSurfaceVariant muted | report-card unmarked rows |
| Coverage complete (100%) | tertiary | MarksSummaryBar full state |
| Coverage partial | primary | MarksSummaryBar progress |
Status enum source: examination.schema.ts:28-33; type enum
examination.schema.ts:15-20.
2. Typography mapping (Ty, 00-shared/02 §2)
| Element | Token |
|---|---|
| Screen titles (Exams, Results) | titleLarge |
| Exam name (card/detail) | titleMedium (card) / headlineMedium (detail) |
| Slot subject, roster student name | titleMedium / bodyLarge |
| Meta lines (dates, class, counts) | bodySmall / bodyMedium onSurfaceVariant |
| Marks values, percentages, totals | labelLarge–displaySmall mono tabular |
| Badges/chips | labelSmall / labelMedium |
| Form labels | labelLarge onSurfaceVariant |
| Empty/error titles | titleMedium; body bodyMedium |
3. Spacing (Sp, 00-shared/02 §3)
| Use | Token |
|---|---|
| Screen padding (phone) | Sp.16 |
| Screen padding (tablet+) | Sp.24 |
| Card internal padding | Sp.16 (large), Sp.12 (rows) |
| Vertical rhythm between sections | Sp.24 (detail), Sp.16 (forms) |
| Row gaps (marks row) | Sp.12; field-to-label Sp.8 |
| Badge/chip padding | Sp.4/Sp.8, Sp.4/Sp.10 |
4. Elevation (El, 00-shared/02 §5)
| Surface | Token |
|---|---|
| AppBar (S1/S2/S3) | El-e1 |
| Cards (ExamCard, SlotHeaderCard, ReportCard) | El-e1 |
| Publish dialog | El-e3 |
| Floating sheets (grade sheet, conflict sheet) | El-e3 |
5. Radius (R, 00-shared/02 §4)
| Element | Token |
|---|---|
| Cards (list/detail) | r-lg (16) |
| Rows, coverage tiles | r-md (12) |
| Inputs, marks field | r-md |
| Badges/chips | r-full |
| MarksSummaryBar | r-full |
6. Iconography
| Icon | Use |
|---|---|
Icons.assignment / fact_check | list empty state, module icon |
Icons.add | FAB "New exam" / "Add subject" |
Icons.edit / Icons.delete | detail menu |
Icons.campaign / Icons.verified | publish button / published badge |
Icons.check_circle (tertiary) | row saved |
Icons.error_outline | failed row |
Icons.cloud_off / Icons.cloud_queue | offline queued row |
Icons.schedule | slot time |
Icons.share | report card share (proposed) |
Icons.warning_amber | publish/conflict/overlap dialogs |
Icons never carry meaning alone (paired with labels, 00-shared/09).
7. Dark/light mapping
- Token-only theming; no hardcoded colors. Status colors invert through the same
role tokens (
primaryContainer↔primaryhandling per 00-shared/02 §7). - Published badge:
tertiaryContainer(light) /tertiary-tinted container (dark). - Unmarked report-card rows:
onSurfaceVariantat 60% opacity in dark. - Screenshots/QA: 14_QA_Checklist.md §dark includes badge contrast spot-checks.
8. Component-to-shared mapping
| Module component | Built on | Shared variants reused |
|---|---|---|
ExamStatusBadge, ExamTypeChip, GradeChip | AppBadge/AppChip | — |
ExamCard, ExamSubjectCard, CoverageTile, SlotHeaderCard, ResultRowCard | AppCard | pressed/hover state layers |
MarksTextField | AppTextField (compact numeric variant) | error/disabled states |
PublishDialog, delete confirm, conflict sheet | AppDialog | barrierDismissible lock while submitting |
MarksSummaryBar | custom progress primitive (shared) | Semantics(liveRegion) |
| Skeleton/empty/error/offline | AppSkeleton, AppEmptyState, AppErrorState, AppOfflineBanner | as-is |
9. Tokens never used
Icons.* decorative animation beyond Mo set; danger-only surfaces for
non-destructive actions; any elevation above El-e3 inside the module.
12 — API Mapping (Exams Module)
- 0. Module-wide request envelope & client policy
- 1. S1 — Exams List
- 2. S2 — Exam Detail
- 3. S3 — Marks Entry
- 4. S4 — Add Subject Slot
- 5. S5 — Publish
- 6. S6 — Student Results (mine)
- 7. S7 — Report Card
- 8. S8 / S8a — Create / Edit Exam
- 9. Events & queues (context, not client endpoints)
- 10. Client error mapping (module)
Exact wire contract for every screen → endpoint. Base
/api/v1(main.ts:44global prefix); envelope per 00-shared/07_API_Conventions.md. All endpoints fromexamination.controller.tsandresult.controller.ts; business rules fromexamination.service.tsandresult.service.ts. All endpoints are guarded byJwtAuthGuardonly (examination.controller.ts:21-24,result.controller.ts:7-10); no per-endpoint RBAC decorators exist yet (OQ-4).
0. Module-wide request envelope & client policy
| Aspect | Contract |
|---|---|
| Base | https://api.<domain>/api/v1 |
| Headers | Authorization: Bearer <accessToken>; x-request-id; Content-Type: application/json; Idempotency-Key (UUID) on mark saves |
| Success | {success, message:'OK', data, meta?, timestamp, requestId} (response-envelope.interceptor.ts:11-18, 47-60) |
| Error | {success, message, error:{code, details?}, timestamp, requestId} (http-exception.filter.ts:16-25, 73-81) |
| Tenancy | tenantId from JWT claim — never in body (00-shared/07 §6) |
| Pagination | page (1-based), limit (1–100, default 20), sort/q accepted but ignored by exams list (OQ-7); meta {page,limit,totalItems,totalPages,hasNext,hasPrevious} (pagination-query.dto.ts:5-30, 41-55) |
| Caching | lists: last-good cache TTL 24 h; marks/coverage: 5 min SWR (00-shared/06 §3.3) |
| Offline | reads cached; mark writes queued with idempotency keys (13_State_Management.md §4); create/edit/publish online-only |
| Retry | backoff on 5xx/network; no auto-retry on 429 |
1. S1 — Exams List
| Endpoint | GET /examinations?page=1&limit=20 (examination.controller.ts:30-32) |
| Request | PaginationQueryDto {page?, limit?, sort?, q?} (pagination-query.dto.ts:5-30) — sort/q ignored by service (OQ-7) |
| Success | 200 → data = ExaminationDocument[], meta = pagination meta (examination.service.ts:67-79; buildPaginationMeta, pagination-query.dto.ts:41-55) |
| Doc shape | `_id, tenantId, academicYearId, name, type('midterm' |
| Errors | 400 bad page/limit; 401; 429; 5xx |
| Source | examination.service.ts:67-79 |
| Offline | cached list (24 h) |
2. S2 — Exam Detail
| Endpoint | GET /examinations/:id (examination.controller.ts:33-35) |
| Success | 200 → data = exam doc (shape above) |
| Errors | 404 RESOURCE_NOT_FOUND "Examination not found." (examination.service.ts:61-65); cross-tenant/deleted → same 404 |
| Endpoint | GET /examinations/:id/subjects (examination.controller.ts:51-53) |
| Success | 200 → data = ExaminationSubjectDocument[] (examination.service.ts:133-137; examination-subject.repository.ts:20-24) — unpaginated |
| Slot shape | _id, examinationId, subjectId, classId, date, startTime('HH:mm'), endTime('HH:mm'), maximumMarks, passingMarks (examination-subject.schema.ts:9-31) |
| Endpoint (coverage) | GET /results/exam-subject/:examSubjectId (result.controller.ts:21-25) → data = ExaminationResultDocument[] (marked count per slot) |
| Result shape | _id, studentId, examinationSubjectId, marksObtained?, grade?, remarks?, publishedAt?, version, createdAt, updatedAt (examination-result.schema.ts:9-25) |
| Errors | 404s as above; 429 |
| Offline | cached header + slots (slots 5 min) |
3. S3 — Marks Entry
| Endpoint | POST /results/exam-subject/:examSubjectId/marks (result.controller.ts:26-31) |
| Request | EnterMarksDto {studentId, marksObtained, grade?, remarks?} (examination-subject.dto.ts:47-66) |
| Success | 201 (create) or 200 (update) → data = result doc; upsert by (tenantId, studentId, examinationSubjectId) unique index (examination-result.schema.ts:31-33); existing doc updated in place (examination.service.ts:147-175); emits MarksEntered {studentId, examinationSubjectId} (examination.service.ts:163-174, 183-193) |
| Business rules | marksObtained > subject.maximumMarks → 404 NotFoundException('Marks cannot exceed maximum.') (examination.service.ts:145-146) — client pre-validates (see 08 §4); slot missing → 404 "Exam subject not found." (examination.service.ts:144); no check of passingMarks, of student↔class membership, or of publish state (OQ-5) |
| Errors | 400 VALIDATION_ERROR (bad ids / negative marks); 404 as above; 429; 5xx |
| Source | examination.service.ts:139-195 |
| Offline | queued with Idempotency-Key = op id; flush FIFO |
4. S4 — Add Subject Slot
| Endpoint | POST /examinations/:id/subjects (examination.controller.ts:45-50) |
| Request | CreateExaminationSubjectDto {examinationId, subjectId, classId, date(ISO), startTime, endTime, maximumMarks, passingMarks} (examination-subject.dto.ts:11-45); examinationId injected from route (examination.service.ts:49) |
| Success | 201 → data = slot doc; emits ExaminationSubjectAdded (examination.service.ts:119-130) |
| Business rules | @Min(1) on both marks fields only; no server check: date/time overlap (OQ-2), duplicate subject (OQ-3), passingMarks ≤ maximumMarks (OQ-6), window ⊆ exam (OQ-6) |
| Errors | 400; 404 exam missing (create fails on repository); 429 |
| Source | examination.service.ts:112-131 |
5. S5 — Publish
| Endpoint | POST /examinations/:id/publish (examination.controller.ts:54-56) |
| Success | 200 → data = void (examination.service.ts:203-220); server stamps publishedAt = now on all results of the exam's slots (examination-result.repository.ts:42-51), sets exam status: 'published' (examination.service.ts:209-211); emits ExamResultsPublished {examinationId, subjectCount} (examination.service.ts:212-219) → queue in-app, job results-published (event-queue-map.ts:27) |
| Business rules | no guards: publishes whatever marks exist (no completeness check); re-callable; does not lock edits (OQ-5); not a DB transaction (two sequential writes) |
| Errors | 404 exam missing (updateById no-op on missing id — client treats as refresh); 429 |
| Source | examination.service.ts:203-220 |
6. S6 — Student Results (mine)
| Endpoint | GET /results/student/:studentId (result.controller.ts:16-20) |
| Success | 200 → data = ExaminationResultDocument[] — unpaginated (result.service.ts:36-38; examination-result.repository.ts:26-28); no publish filter — unpublished marks are returned |
| Errors | 401; 429 |
| Note | client groups rows by exam via slot ids (subject/exam names resolved client-side, OQ-9) |
7. S7 — Report Card
| Endpoint | GET /results/report-card/:studentId/:examId (result.controller.ts:32-37) |
| Success | 200 → data = ReportCard {studentId, examinationId, subjects:[{subjectId, subjectName(==raw subjectId, OQ-9), marksObtained, maximumMarks, grade?, remarks?}], totalMarksObtained, totalMaximumMarks, percentage(2dp), overallGrade, generatedAt(ISO)} (result.service.ts:8-26, 46-98) |
| Business rules | no subjects → 404 "No subjects found for this examination." (result.service.ts:51-53); missing marks count as 0 (result.service.ts:70); overallGrade bands A+(≥90)/A(≥80)/B+(≥70)/B(≥60)/C(≥50)/D(≥40)/F (result.service.ts:130-138) |
| Errors | 404 as above; 429 |
8. S8 / S8a — Create / Edit Exam
| Endpoint | POST /examinations (examination.controller.ts:27-29) |
| Request | CreateExaminationDto {academicYearId, name, type, startDate, endDate} (examination.dto.ts:4-26); server forces status:'draft' (examination.service.ts:48); emits ExaminationCreated {examinationId, name} (examination.service.ts:50-57) |
| Success | 201 → data = exam doc |
| Endpoint | PATCH /examinations/:id (examination.controller.ts:36-41) |
| Request | UpdateExaminationDto {name?, type?, startDate?, endDate?, status?} (examination.dto.ts:28-53); $set only sent fields, version +1 (base.repository.ts:57-66); emits ExaminationUpdated {examinationId, changes:[keys]} (examination.service.ts:88-96) |
| Errors | 400; 404 "Examination not found." (examination.service.ts:85-87); free-string status not enum-enforced on PATCH (OQ-5) |
| Endpoint (delete) | DELETE /examinations/:id (examination.controller.ts:42-44) — soft delete (examination.service.ts:99-110, base.repository.ts:68-74); emits ExaminationDeleted; no cascade to slots/results; 404 if already deleted |
9. Events & queues (context, not client endpoints)
| Item | Source |
|---|---|
ExamResultsPublished → queue in-app, job results-published | event-queue-map.ts:27 |
ExaminationCreated/Updated/Deleted, ExaminationSubjectAdded, MarksEntered — emitted but no BullMQ route (no consumers today) | examination.service.ts:50-57, 88-96, 102-110, 119-130, 163-174, 183-193; event-queue-map.ts |
ExaminationResultCreated (Results module path) | result.service.ts:114-126 |
Queues available (emails, push, whatsapp, in-app, …) | queue.constants.ts:1-17 |
Planned exams endpoints: /:id/schedule, /hall-tickets, /seating-plan, /marks-import, /re-evaluation | (planned) docs/IMPLEMENTATION_PLAN.md:213-220 |
Mock test / DPP / test series (createMockTest, createDPP, createTestSeries, exam type enum extension) | (planned) docs/IMPLEMENTATION_PLAN.md:501-567, 643 |
10. Client error mapping (module)
| Code | Exams-specific UX |
|---|---|
400 VALIDATION_ERROR | inline field errors (08 §5); bulk → pre-validate client-side |
401 UNAUTHENTICATED | refresh once; else session expiry (00-shared/06 §5) |
403 PERMISSION_DENIED | hide plan/publish/marks actions; read-only state (RBAC (planned), OQ-4) |
404 RESOURCE_NOT_FOUND | two special cases: "Marks cannot exceed maximum." → refresh slot max + re-enter (examination.service.ts:146); "Exam subject not found." → pop to detail (examination.service.ts:144); others → purge cache + return to list |
409 DUPLICATE_RESOURCE | not expected today (no unique constraints on exams/slots, OQ-3); offline flush collision preview |
422 BUSINESS_RULE_Violation | reserved for future conflict/immutability checks — not emitted by exams service today (OQ-2/OQ-5) |
429 RATE_LIMITED | backoff, no auto-retry |
| 5xx | generic + requestId; retry; last-good cache |
13 — State Management (Exams Module)
- 1. Cubits & responsibilities
- 2. ExamDetailCubit state machine
- 3. MarksEntryCubit — the heart
- 4. Offline marks queue (module-defined;
00-shared/06 §3.7) - 5. ExamsListCubit
- 6. StudentResultsCubit / ReportCardCubit
- 7. Cross-cutting
Per-screen state on top of 00-shared/06_State_Management.md (Bloc/Cubit, repository layer, SWR cache, WS, optimistic-update rules, offline queue contract). The module's defining problems: idempotent marks-entry with an offline queue, publish as a never-optimistic side-effect, and server truth for the published state.
1. Cubits & responsibilities
| Cubit | Screen(s) | State |
|---|---|---|
ExamsListCubit | S1 | paginated exam list, status/type filters (client-side — server ignores q/sort, OQ-7), cache |
ExamDetailCubit | S2 | header + slots + coverage per slot; publish lifecycle |
ExamFormCubit | S4, S8, S8a | create exam / edit exam / add slot; client-only validations (OQ-2/3/6) |
MarksEntryCubit | S3 | roster rows + marks, per-row save state machine, offline queue |
StudentResultsCubit | S6 | marks list for a student, grouped by exam |
ReportCardCubit | S7 | report card aggregate |
All implement LoadState {Initial, Loading, Success, Error} (00-shared/06 §3.1);
pagination only where the API paginates (exams list; S3/S6/S7 reads are
unpaginated arrays — client-side handling).
2. ExamDetailCubit state machine
flowchart TD
A[Initial] -->|load exam+slots| B[Loading]
B -->|200| C[Success<br/>header + slots]
B -->|404| E[ErrorNotFound<br/>'Examination not found.']
B -->|5xx/offline| F[Error<br/>+ last-good cache]
C -->|slot tapped| G[Open marks screen]
C -->|publish confirmed| H[Publishing<br/>spinner, dialog locked]
H -->|204/200| I[Published<br/>status='published' badge]
H -->|error| F
E -->|back| A
- Publish is never optimistic: badge flips only from the server response
(
examination.service.ts:209-211);POST /examinations/:id/publishis online-only, no offline queue. - After
published, the cubit exposeslocked = true→ UI hides Publish/Edit/Delete/Add-subject (client immutability contract, OQ-5).
3. MarksEntryCubit — the heart
flowchart TD
A[Initial] -->|load roster + marks| B[Loading]
B -->|200| C[Ready<br/>rows: student + marks? + saveState]
B -->|404 slot| D[SlotGone<br/>pop to detail]
C -->|type/edit value| E[Editing<br/>row.saveState=unsaved]
E -->|save (Enter/Tab)| F[Saving<br/>optimistic value shown]
F -->|201/200 upsert| G[Saved<br/>check, version from server]
F -->|404 max exceeded| H[MaxViolation<br/>inline error, refresh max]
F -->|network fail| I[Failed<br/>shake + Retry]
C -->|offline detected| J[Queued<br/>dashed row + cloud icon]
J -->|reconnect flush| F
I -->|Retry| F
G -->|next row| E
- Row state machine:
unsaved → saving → saved | failed | queued; rollback on failure (restore previous server value). - Optimistic policy (
00-shared/06 §3.5): single mark save is optimistic with server-confirm (upsert is idempotent —examination.service.ts:147-175, unique indexexamination-result.schema.ts:31-33); row reverts on 4xx/5xx. Publish is pessimistic (side effects). - Save batching: saves are serialized per row (one in-flight POST per row);
no bulk endpoint today (
/marks-importis(planned),IMPLEMENTATION_PLAN.md:219).
4. Offline marks queue (module-defined; 00-shared/06 §3.7)
MarkOp = {opId: uuid, kind: 'marks', payload: EnterMarksDto + examSubjectId, createdAt, tenantId}
- Capture: offline or write-error → op persisted (Hive box
exm.queue, key{tenant}:{examSubjectId}), rows flaggedqueued. - Dedupe: before enqueue, replace queued ops for the same
studentIdin the sameexamSubjectId(last-write-wins mirrors the server upsert). - Flush: on reconnect (
ConnectivityCubit) →POST /results/exam-subject/:id/marksper op withIdempotency-Key= opId; success → remove op + mark saved; failure → keep, backoff retry, cap 5 attempts, then "N marks waiting — review". - Conflict on flush (409): fetch server doc for
studentId+examSubjectId; if it differs from the queued payload → row flaggedconflicted, preview sheet (server vs local), user confirms overwrite or discards. - Ordering: FIFO within a slot; cross-slot FIFO by
createdAt. - Rows written while the exam flips to
published→ confirm-before-send sheet (publish and offline queue interaction, OQ-5).
5. ExamsListCubit
- Paginated fetch
GET /examinations?page&limit(examination.service.ts:67-79); append pages whilehasNext(pagination-query.dto.ts:41-55); pull-to-refresh resets to page 1. - Client-side filter/sort (server ignores
sort/q— OQ-7): chips filter bystatus/typeover the loaded window; sort bystartDateasc locally; a "fetched all pages" footer whenhasNext: false. - Cache: last-good list TTL 24 h; invalidated on create/delete from
ExamFormCubit/ExamDetailCubit(module-internal event). - Realtime: subscribe to
results.publishedWS topic (00-shared/07 §8)(planned)→ re-fetch affected exam row (badgepublished).
6. StudentResultsCubit / ReportCardCubit
- S6:
GET /results/student/:studentId(result.service.ts:36-38) — unpaginated; client groups by exam client-side (names resolved from slots, OQ-9). Cache TTL 5 min; pull-to-refresh bypasses cache. - S7:
GET /results/report-card/:studentId/:examId(result.service.ts:46-98); cacheable 24 h when the exam is published (server stampspublishedAt,examination-result.repository.ts:42-51); otherwise 5 min. 404 ("No subjects found…",result.service.ts:51-53) →NotFoundstate, not retried.
7. Cross-cutting
- Auth/session: all cubits react to
sessionExpired→ preserve unsaved/queued marks, redirect login, restore on re-login (00-shared/06 §3.6). - Permissions:
exam.*/result.*(planned)(OQ-4); until seeded, client gates by role claim defensively — server authoritative (JwtAuthGuardonly today,examination.controller.ts:24). - Testing hooks: pure-Dart cubits with mocked repositories; widget tests for
the row save state machine, max-violation path, publish non-optimistic flip, and
offline queue flush (
00-shared/06 §6).
14 — QA Checklist (Exams Module)
- 1. Functional — planning (S1/S2/S4/S8)
- 2. Validation & edge cases (module-specific)
- 3. Errors & resilience
- 4. A11y (00-shared/09)
- 5. Responsive & theming
- 6. Performance
- 7. Security & tenancy
- 8. Analytics
(proposed)
Test checklist grounded in the implemented backend contract. Every item is traceable to a source behavior; run against 00-shared/10_QA_Baseline.md. Coverage targets: unit (cubits/forms), widget, integration (mock API), E2E (against
npm run test:e2estack with MongoDB + Redis).
1. Functional — planning (S1/S2/S4/S8)
-
List loads page 1 (limit default 20); scrolling appends pages;
hasNext: falseshows end-of-list; pull-to-refresh resets to page 1 (examination.service.ts:67-79). -
Create exam → POST returns doc with
status: 'draft'regardless of payload (examination.service.ts:48); detail shows badgedraft. -
Edit exam → PATCH
$setonly sent fields;versionincrements (base.repository.ts:57-66); unchanged fields untouched. -
Delete exam → soft delete; exam disappears from list; slots/results remain
(no cascade —
examination.service.ts:99-110); direct slot read after delete → 404. -
Add subject slot → appears in
GET /examinations/:id/subjectsorder;examinationIdtaken from route, never from body (examination.service.ts:49). -
Slot fields persist exactly:
date(Date),startTime/endTimeas'HH:mm'strings,maximumMarks/passingMarksnumbers (examination-subject.schema.ts:18-31).
2. Validation & edge cases (module-specific)
-
Max marks: submit
marksObtained > maximumMarks→ server returns 404NotFoundException"Marks cannot exceed maximum." (examination.service.ts:145-146); client pre-validation blocks typing beyond max digits and shows inline error; server 404 path → slot-max refresh + re-enter. -
Pass marks: client blocks
passingMarks > maximumMarks(OQ-6); server accepts (both@Min(1)only,examination-subject.dto.ts:36-44) — confirm the warning banner onSlotHeaderCardwhen an invalid config exists server-side. -
Duplicate exam-subject: adding the same subject twice succeeds server-side
(non-unique index,
examination-subject.schema.ts:37) — client warns "already scheduled" (OQ-3); confirm warn-then-proceed flow. - Subject date conflicts: no server rejection (OQ-2) — verify the overlap warning dialog on submit and that "add anyway" proceeds.
-
Exam window inversion (
startDate > endDate) and time inversion (endTime ≤ startTime) → client-only inline errors; server accepts — verify the client refuses to submit (OQ-6). -
Publish immutability: after publish — Publish/Edit/Delete/Add-subject
hidden; marks edits show published confirm sheet (OQ-5). Verify against server:
re-POST publish succeeds (no guard,
examination.service.ts:203-220) and PATCH status'garbage'persists without enum validation (updateByIdnorunValidators) — document as known server gaps, client must not rely on them. -
Status transitions: create →
draft; publish →published;active/completedonly via explicit user action (OQ-1); client never sendsstatuson create; status picker disabled whenpublished. -
Missing marks on report card: subject without result →
0/max+ "not marked" hint; all-unmarked exam → percentage 0, grade F with explanatory note (result.service.ts:70, 83-95, 137). -
Empty slot list on report card: 404 "No subjects found for this
examination." → empty state, no retry loop (
result.service.ts:51-53). -
Free-text per-subject grade: grade sent as-is (A+/A/B+/B/C/D/F suggested);
empty grade renders "—" (
examination-subject.dto.ts:57-60; OQ-9). -
Marks upsert: same student+slot saved twice → single doc, second write
updates in place (
examination.service.ts:147-175); unique index{tenantId, studentId, examinationSubjectId}(examination-result.schema.ts:31-33).
3. Errors & resilience
-
404 "Examination not found." → purge cache, pop to list, snackbar
(
examination.service.ts:63, 87, 101). -
404 "Exam subject not found." → pop to detail, refresh slots
(
examination.service.ts:144). -
400
VALIDATION_ERROR→ inline field errors, first invalid focused. - 429 → backoff + countdown, no auto-retry (00-shared/07 §4).
-
5xx → generic +
requestId, retry offered, last-good cache served. - Offline marks entry: rows queue (dashed + cloud icon); flush on reconnect with idempotency; dedupe last-write-wins; 5-attempt cap surfaces "review" state; 409 flush collision → preview sheet (overwrite/discard) (13 §4).
- Publish offline → disabled (online-only); never optimistic flip (13 §2).
4. A11y (00-shared/09)
-
Status badges: icon + label + text (never color-only); screen reader reads
"status {status}". -
Marks rows:
"{student}, {marks} of {max}"; errors announced; Enter saves + advances focus; Tab order stable; 48 dp targets; reduced motion halves durations. - Publish dialog: focus trap, Escape cancels, progress announced; live-region on coverage bar and publish badge flip.
- Report card table: headers announced; grade badge label with threshold text.
-
Color contrast in dark mode:
tertiaryContainerbadges, muted "not marked" rows ≥ 4.5:1.
5. Responsive & theming
- Phone (≤600 dp): stacked cards, marks rows full-width, sheets for grade/ conflict.
- Tablet (≥840 dp): master–detail list+detail; marks screen right rail; report card real table.
- Dark/light: token-only swap; published badge & coverage bar verified in both.
-
Keyboard:
Nnew exam,Ppublish (desktop); no overflow at 320 dp width.
6. Performance
- List first page < 500 ms p95 (cached); marks save round-trip < 500 ms p95.
-
Coverage tab N+1 bounded: one
GET /results/exam-subject/:idper slot, serialized or throttled ≤ 4 concurrent; no re-fetch on tab switch without change. - Marks screen with 60-student roster: no jank, row save never blocks typing.
- Report card cached 24 h when published; 5 min otherwise (13 §6).
7. Security & tenancy
-
Cross-tenant
:id→ 404, never leaks existence (base.repository.ts:20-30). -
tenantIdnever sent in bodies;x-request-idechoed (00-shared/07 §6,response-envelope.interceptor.ts:44). - No secrets in logs/screens; error UI uses server codes + i18n keys (00-shared/07 §11).
-
RBAC: when
exam.*lands (OQ-4) — 403 hides actions, read-only state, server authoritative.
8. Analytics (proposed)
- Events per 05_Screen_Inventory.md fire on: list view, row tap, create start, detail view, publish tap/success/failure, marks open/save/fail, coverage, slot add (+conflict warned), results view, report card view/share.
15 — Flutter Implementation Guide (Exams Module)
- 1. Folder structure
- 2. Models (server-exact)
- 3. API client
- 4. State wiring (BlocProvider scope)
- 5. Offline queue (marks)
- 6. Key implementation rules
- 7. Build order
- 8. Packages (all shared, no new deps)
Concrete Flutter blueprint for the Exams module, aligned with 00-shared/11_Flutter_App_Architecture.md (clean architecture: presentation → application → domain → data; Bloc/Cubit state; repository pattern) and 00-shared/06_State_Management.md. All models map 1:1 to the server contracts in 12_API_Mapping.md.
1. Folder structure
lib/
└── features/exams/
├── exams_module.dart # module wire-up (routes, DI, cubits)
├── domain/
│ ├── entities/
│ │ ├── examination.dart # mirrors examination.schema.ts:9-36
│ │ ├── examination_subject.dart # mirrors examination-subject.schema.ts:9-31
│ │ └── examination_result.dart # mirrors examination-result.schema.ts:9-25
│ ├── enums/
│ │ ├── examination_type.dart # midterm|final|unit_test|quarterly|other
│ │ └── examination_status.dart # draft|active|completed|published
│ └── repositories/
│ ├── examinations_repository.dart # abstract
│ ├── examination_subjects_repository.dart
│ ├── examination_results_repository.dart
│ └── report_card_repository.dart
├── data/
│ ├── models/ # fromJson/toJson + envelope parsing
│ │ ├── examination_model.dart
│ │ ├── examination_subject_model.dart
│ │ ├── examination_result_model.dart
│ │ └── report_card_model.dart # report-card aggregate (result.service.ts:8-26)
│ ├── datasources/
│ │ └── exams_api.dart # dio client, /api/v1, auth + envelope
│ └── repositories_impl/
│ ├── examinations_repository_impl.dart
│ ├── examination_subjects_repository_impl.dart
│ ├── examination_results_repository_impl.dart
│ └── report_card_repository_impl.dart
├── application/
│ ├── cubits/
│ │ ├── exams_list_cubit.dart
│ │ ├── exam_detail_cubit.dart
│ │ ├── exam_form_cubit.dart
│ │ ├── marks_entry_cubit.dart
│ │ ├── student_results_cubit.dart
│ │ └── report_card_cubit.dart
│ └── offline/marks_offline_queue.dart # Hive box 'exm.queue' (13 §4)
└── presentation/
├── screens/
│ ├── exams_list_screen.dart # S1
│ ├── exam_detail_screen.dart # S2 (+ publish dialog S5)
│ ├── marks_entry_screen.dart # S3
│ ├── add_subject_slot_screen.dart # S4
│ ├── exam_form_screen.dart # S8/S8a
│ ├── student_results_screen.dart # S6
│ └── report_card_screen.dart # S7
├── widgets/ # 07_Component_Library.md
│ ├── exam_status_badge.dart
│ ├── exam_type_chip.dart
│ ├── exam_card.dart
│ ├── exam_subject_card.dart
│ ├── marks_row.dart
│ ├── marks_summary_bar.dart
│ ├── grade_chip.dart
│ ├── coverage_tile.dart
│ ├── publish_button.dart / publish_dialog.dart
│ ├── slot_header_card.dart
│ ├── result_row_card.dart
│ ├── report_card_table.dart
│ └── grade_badge.dart
└── routes.dart # /exams, /exams/:id, /exams/:id/slots/:id/marks,
# /exams/:id/slots/new, /exams/new, /exams/:id/edit,
# /results, /results/:examId/report-card
2. Models (server-exact)
enum ExaminationStatus { draft, active, completed, published } // examination.schema.ts:28-33
enum ExaminationType { midterm, final, unitTest, quarterly, other } // :15-20
class Examination {
final String id, academicYearId, name;
final ExaminationType type;
final DateTime startDate, endDate; // ISO → local
final ExaminationStatus status;
final String? gradingSchemeId;
final int version; // base.schema.ts:30-31
}
class ExaminationSubject {
final String id, examinationId, subjectId, classId;
final DateTime date;
final String startTime, endTime; // 'HH:mm' strings — never parsed to DateTime
final int maximumMarks, passingMarks;
}
class ExaminationResult {
final String id, studentId, examinationSubjectId;
final int? marksObtained; // absent = unmarked (report card counts 0)
final String? grade, remarks;
final DateTime? publishedAt;
final int version;
}
class ReportCard { ... } // result.service.ts:8-26, 88-97
Envelope parsing via shared ApiResponse<T> (00-shared/07 §2-3); error decoding
uses error.code (never raw server text except 4xx business messages,
00-shared/07 §11).
3. API client
- Base:
https://api.<domain>/api/v1(main.ts:44prefixapi/v1). - Endpoints (exact,
examination.controller.ts/result.controller.ts):
| Method | Path | Notes |
|---|---|---|
| POST | /examinations | create, forced draft |
| GET | /examinations?page&limit | paginated; sort/q ignored (OQ-7) |
| GET | /examinations/:id | 404 "Examination not found." |
| PATCH | /examinations/:id | partial $set, version+1 |
| DELETE | /examinations/:id | soft delete |
| POST | /examinations/:id/subjects | slot create |
| GET | /examinations/:id/subjects | unpaginated slots |
| POST | /examinations/:id/publish | stamps publishedAt, status→published |
| POST | /results/exam-subject/:id/marks | marks upsert (EnterMarksDto) |
| GET | /results/exam-subject/:id | slot marks (coverage) |
| GET | /results/student/:studentId | own results |
| GET | /results/report-card/:studentId/:examId | aggregate |
- Marks save sends
Idempotency-Key: <opId>(offline flush);x-request-idgenerated per request (00-shared/07 §1). studentIdfor S6/S7 comes from the auth/profile claim — never typed.
4. State wiring (BlocProvider scope)
ExamsModule(
child: MultiBlocProvider(providers: [
BlocProvider(create: (_) => ExamsListCubit(examsRepo)),
BlocProvider(create: (_) => ExamDetailCubit(subjectsRepo, resultsRepo, marksRepo)),
BlocProvider(create: (_) => MarksEntryCubit(resultsRepo, marksOfflineQueue)),
BlocProvider(create: (_) => ExamFormCubit(examsRepo, subjectsRepo)),
BlocProvider(create: (_) => StudentResultsCubit(resultsRepo)),
BlocProvider(create: (_) => ReportCardCubit(reportRepo)),
]),
)
ExamDetailCubitscoped perexamId(recreated on route change);MarksEntryCubitscoped perexamSubjectId.- Cubits expose
LoadState-based sealed states (13_State_Management.md); screens areBlocBuilder/BlocListeneronly — no logic in widgets.
5. Offline queue (marks)
class MarksOfflineQueue {
final HiveBox<MarkOp> box = Hive.box('exm.queue'); // key: {tenant}:{examSubjectId}
// enqueue: dedupe by studentId (last-write-wins), FIFO flush on ConnectivityCubit
// flush: POST per op + Idempotency-Key; 5 attempts → "review" state;
// 409 → conflict preview (server vs local doc)
}
Wire ConnectivityCubit (shared) → MarksEntryCubit.flushQueue(); queue survives
session expiry (00-shared/06 §3.6-3.7).
6. Key implementation rules
- Publish is never optimistic — button awaits
POST /examinations/:id/publish, then reads the refreshed detail (badgepublished); dialog isbarrierDismissible: falsewhile submitting. - Max-marks pre-validation in
MarksTextField(digits ≤maximumMarks, clamp on paste) mirrors the server guardexamination.service.ts:145-146; the 404 path refreshes the slot's max before re-entry. - Immutability flag
exam.status == publishedgates: Publish, Edit, Delete, Add-subject, and marks edits (confirm sheet) — client-side contract (OQ-5). - Client-only validations (OQ-2/3/6): slot overlap warning, duplicate-subject
warning,
passingMarks ≤ maximumMarks, window ⊆ exam dates,endTime > startTime,endDate ≥ startDate— enforced inExamFormCubitbefore any request. - Status picker only offers
draft → activetransition (OQ-1); create never sendsstatus. - Report card: resolve
subjectNamefrom Academics subjects catalog bysubjectId(payload returns raw id — OQ-9); unmarked rows → "0 + not marked" hint; percentage renderedtoStringAsFixed(1)from server's 2-dp value. - Names/formatting: times displayed from
startTime/endTimestrings (never re-parsed); dates from ISODateTimelocalized.
7. Build order
- Domain entities + enums + repositories (interfaces) — compile-time contract.
- Data layer:
exams_api.dart(envelope/error decoding), models, repo impls, unit tests with mocked dio. - Cubits + offline queue — pure Dart, unit-tested state machines (
13 §7). - Screens S1 → S2 → S3 (core value) with shared widgets
(
00-shared/03+07module). - S8/S4 forms (
08_Form_Specifications.md), S5 publish dialog. - S6/S7 read-only branch (student/parent).
- A11y pass (
00-shared/09), dark mode, tablet layouts, analytics(proposed)instrumentation per05. - E2E against live stack (
npm run test:e2e— MongoDB + Redis) for14_QA_Checklist.mdscenarios.
8. Packages (all shared, no new deps)
flutter_bloc, dio (+ interceptors for auth/request-id), hive (offline queue),
intl (dates), go_router (routes incl. deep links studylyon://exams/:id
(forward-looking)), share_plus (report-card share (proposed)), existing shared
design system package (00-shared/02/03).
01 — Product Overview (Feature Flags Module)
- 1. Purpose
- 2. Business goals
- 3. User goals
- 4. Stakeholders
- 5. Why this exists
- 6. Dependencies
- 7. Success metrics
- 8. Edge cases (client-relevant)
- 9. Assumptions (module)
- 10. Open questions (module-level; global ledger in 00-shared/12)
- 11. Glossary (this module)
StudyLyon — multi-tenant ERP / School Management API. This package designs the Feature Flags module client (Flutter, forward-looking spec) against the implemented NestJS backend. Every endpoint, DTO field, schema, permission, and wire contract below is derived directly from
src/modules/feature-flags/**,src/modules/organizations/**,src/modules/rbac/permissions.constants.ts,src/database/base.repository.ts,src/infrastructure/bullmq/queue.constants.ts, and the studylyon-blueprint handbook. No feature is invented; gaps are flagged in Assumptions & Open Questions and marked(planned)/(forward-looking)/(proposed)per global rules.
1. Purpose
Feature Flags let a tenant (institution) turn optional platform capabilities on and off —
biometric attendance, SMS, WhatsApp channels, AI reports, payroll, inventory, and similar
channel/integration toggles (blueprint 03-Database/COLLECTIONS.md:785-801). It is the
configuration surface for "gradual rollout" and capability gating: downstream services are
supposed to read a flag before offering a feature (PLAN.md:29 — scenario 2.6
"Configure feature flags (biometric, SMS, etc.) … Feature-toggle read in downstream services").
| Responsibility | Source |
|---|---|
CRUD of tenant feature toggles (key, enabled, label, description, module) | feature-flag.schema.ts:9-22 |
| List all / by module / enabled-only flag sets | feature-flags.controller.ts:23-34 |
Keyed upsert (create-or-update, merge on enabled+label) | feature-flag.repository.ts:32-43 |
| Bulk upsert (sequential, no transaction) | feature-flags.service.ts:37-45 |
| Soft-delete of a flag | feature-flags.service.ts:47-52 |
| Evaluation primitive for downstream services | feature-flags.service.ts:22-25 isEnabled() |
| Org-level flag map (lifecycle surface) | organizations.controller.ts:74-89, organizations.service.ts:139-154 |
| RBAC permission names (declared) | permissions.constants.ts:78-80 |
There are two distinct flag surfaces in the backend, and they are not connected in code:
- Flag store — the
feature_flagscollection (feature-flag.schema.ts:7): a per-tenant catalog of toggles with metadata. Served by/api/v1/feature-flags*. - Org lifecycle overlay —
organizations.metadata.featureFlags: Record<string, boolean>(organization.schema.ts:129-130), served byGET/PATCH /api/v1/organizations/:id/feature-flags.PATCHfull-replaces the map (organizations.service.ts:149-151).
FeatureFlagsService.isEnabled() reads only the flag store (feature-flags.service.ts:22-25).
The blueprint assigns "Feature flag evaluation" to the Organizations module
(blueprint 04-Modules/Organizations.md:17) — this integration does not exist yet (OQ-2).
2. Business goals
| Goal | Measure |
|---|---|
| Tenant can enable/disable channel features without a deploy | flag toggle round-trip < 1 s p95 (single Mongo upsert, no cache layer today) |
| Flags are per-tenant, never cross-tenant | every read scoped by tenantId from JWT (base.repository.ts:20-30) |
| Flags survive tenant reuse of keys | unique {tenantId, key} index (feature-flag.schema.ts:26) |
| Feature rollout can be staged per module | module field + {tenantId, module} index (feature-flag.schema.ts:22,27) |
| Downstream services gate on flags | isEnabled() primitive exists (feature-flags.service.ts:22-25); no consumer yet (OQ-1) |
| Config changes are auditable | flag toggles listed under Config audit category — (planned) (AUDITING.md:52) |
| Cache keeps flag evaluation cheap | sl:global:featureflag:{name}, poll + cache, 30 s TTL — (planned) (CACHE_ARCHITECTURE.md:15,33,48) |
3. User goals
- Org admin: browse all toggles for my school, see what's on/off per module, flip a channel (biometric, SMS, WhatsApp) and trust it applies.
- Feature configurator (admin delegate): edit labels/descriptions, batch-enable a rollout (bulk update), fix a mistake (rollback a flag).
- Platform admin: read flags across tenants during support; platform-level toggles.
- End users (teacher/staff/parent/student): never see a disabled feature — UI hides or explains it; no crashes when a flag flips mid-session.
4. Stakeholders
Platform operator (SaaS vendor — tenant tiers/plan gating (proposed)), institution admins,
downstream module owners (biometric, notifications, payments — all forward-looking), support
staff (misconfigured channels), QA + design + engineering consuming these docs.
5. Why this exists
One codebase serves thousands of institutions with different capabilities (school vs coaching
(planned) matrix, IMPLEMENTATION_PLAN.md:310-336) and different channel entitlements
(WhatsApp BSP approval, biometric hardware availability — FEATURE_ROADMAP.md:54-57). Flags
decouple code presence from feature availability without per-tenant branches. The
blueprint lists "Feature toggles / Gradual rollout / A/B testing" as the module's
responsibilities (MODULE_ARCHITECTURE.md:304-312); only the toggle CRUD exists in code
— gradual rollout and A/B testing are (planned) (OQ-6). The roadmap slots the framework in
Phase 3 (FEATURE_ROADMAP.md:42).
6. Dependencies
| Dependency | Role | Source |
|---|---|---|
Auth (JwtAuthGuard, global) | every flag endpoint requires bearer JWT | feature-flags.module.ts:12, app.module.ts:130 |
RBAC (global RbacGuard) | permission check — no flag endpoint declares @Permissions → effectively JWT-only (OQ-5) | app.module.ts:131, rbac.guard.ts:29 |
| Rate limit (global) | api tier default 100/min | rate-limit.constants.ts:6, rate-limit.guard.ts:36 |
| TenantContextService | tenant isolation + platform-admin bypass | base.repository.ts:20-30 |
Mongoose feature_flags | system of record | feature-flags.module.ts:13-15 |
Redis + BullMQ cache-rebuild | declared, not wired to flags — flag cache invalidation (planned) | queue.constants.ts:13, scheduler.service.ts:27,67 |
| Organizations module | org-level flag overlay surface | organizations.controller.ts:74-89 |
7. Success metrics
- Flag list/filter/read latency < 200 ms p95 (direct Mongo, no cache today —
CACHE_ARCHITECTURE.md:48proposes 30 s cache). - Toggle write p95 < 1 s; bulk of ≤ 20 flags applies in < 3 s (sequential loop, OQ-3).
- Zero cross-tenant flag leakage (scopedFilter enforced).
isEnabled()is consumed by ≥ 1 downstream module (biometric / notifications) — target; no consumer today (OQ-1).- No 5xx storm from the delete→re-create E11000 edge (OQ-4).
- Flag-change propagation to client ≤ 30 s (poll TTL) once cache lands.
8. Edge cases (client-relevant)
- Upsert drops
descriptionandmodule: DTO accepts them (update-feature-flag.dto.ts:13-26) butFeatureFlagRepository.upsertonly$setsenabledandlabel(feature-flag.repository.ts:40). Editing a flag's description/module silently persists nothing. PUT /feature-flagswith a new key = create (upsert);enabledis required → you cannot create a "disabled-by-default" flag without sendingenabled:false.- Bulk update is sequential, no transaction (
feature-flags.service.ts:40-44): a failing item mid-list leaves earlier writes applied. - Delete is soft: flag vanishes from every list (
base.repository.ts:20-30), but the unique{tenantId,key}index slot persists — re-creating the same key after delete can hit E11000 → 500 (OQ-4). - Org-level
PATCHfull-replaces: flags omitted from the body are lost (organizations.service.ts:149-151). - Missing key → 404
RESOURCE_NOT_FOUND("Feature flag "" not found." — feature-flags.service.ts:29). - Platform admin: reads bypass tenant scope (
base.repository.ts:21-23), but upsert callsrequireTenantId()directly (feature-flag.repository.ts:37) — a platform-admin write without a tenant context fails (OQ-5).
9. Assumptions (module)
- The client (Flutter) is forward-looking; the backend surface is complete and is the contract source.
feature-flags.read/update/deleteare the canonical permission names (permissions.constants.ts:78-80); the UI is designed so the server can start enforcing them without UI redesign (OQ-5).- The feature_flags collection has no seed data in code — tenants start with an empty
catalog; the UI must render a useful empty state and the client ships no hardcoded keys.
Default per-institution matrices are
(planned)(IMPLEMENTATION_PLAN.md:310-336). - Flag evaluation cache (30 s poll,
sl:global:featureflag:{name}) is documented in the blueprint, not implemented (CACHE_ARCHITECTURE.md:33,48); the client must therefore treat server responses as immediately authoritative and re-fetch per its own TTL. - "Gradual rollout" and "A/B testing" are blueprint responsibilities (
MODULE_ARCHITECTURE.md:310-312) with no API support — no percentage/audience fields exist on the schema (feature-flag.schema.ts:9-22).
10. Open questions (module-level; global ledger in 00-shared/12)
| # | Item | Impact |
|---|---|---|
| OQ-1 | isEnabled() has no consumers in src/ today (grep: only feature-flags.service.ts:22). Which downstream module (biometric, notifications, payments) evaluates flags first, and when? | All "disabled-feature" UX in this package |
| OQ-2 | Org lifecycle overlay (organizations.metadata.featureFlags) is disconnected from the flag store. Which surface wins? Should PATCH /organizations/:id/feature-flags merge instead of full-replace? | Affected-features map, admin confusion |
| OQ-3 | Bulk update: sequential awaits, no transaction/rollback (feature-flags.service.ts:40-44). Accept partial apply, or wrap in a transaction? | Bulk UX (which rows succeeded) |
| OQ-4 | Re-creating a soft-deleted key hits the unique {tenantId,key} index → E11000 → 500 (feature-flag.repository.ts:38-42). Permanent delete endpoint, or hard-delete in upsert, or accept 500? | Rollback journey |
| OQ-5 | feature-flags.* permissions exist (permissions.constants.ts:78-80) but no endpoint declares @Permissions(...) (rbac.guard.ts:29 passes when metadata is absent). When is enforcement added? Also: platform-admin upsert (tenantId requirement). | Route gating, admin UX |
| OQ-6 | Gradual rollout / A/B testing (MODULE_ARCHITECTURE.md:310-312): no fields, no endpoints. Planned phase? | Flag editor fields (percentage/audience) |
| OQ-7 | No WS topic for flag changes (00-shared/07 §8 topic list); no domain event (FeatureFlagChanged) in code. Realtime propagation (planned)? | Client TTL vs push |
11. Glossary (this module)
| Term | Meaning |
|---|---|
| Flag | One feature_flags doc: key, enabled, label?, description?, module? + BaseSchema fields |
| Key | Unique per tenant identifier (biometric.enabled, channels.whatsapp — examples only; keys are free-form strings, update-feature-flag.dto.ts:5-7) |
| Upsert | findOneAndUpdate(..., {upsert:true}) — insert or $set merge on enabled+label (feature-flag.repository.ts:38-42) |
| Enabled set | The list returned by GET /feature-flags/enabled — the client's gate data |
| Flag store | The feature_flags collection (module surface) |
| Org overlay | organizations.metadata.featureFlags map (lifecycle surface, full-replace semantics) |
| Soft delete | isDeleted:true; excluded from all scoped queries (base.repository.ts:20-30) |
| Envelope | {success,message,data,meta?,timestamp,requestId} (response-envelope.interceptor.ts:50-59) |
02 — User Personas (Feature Flags Module)
- 1. Org Admin (org_admin) — owner of the flag catalog
- 2. Feature Configurator (admin delegate with
feature-flags.update) - 3. End Users Affected by Flags (teacher, staff, parent, student)
- 4. Platform Admin (platform_admin, cross-tenant support)
- 5. Downstream Module Consumers (biometric, notifications, payments) —
(forward-looking) - 6. Role → flag-appearance matrix
Roles, goals, permissions, and expected behavior for every consumer of Feature Flags. Permission values are exact from
src/modules/rbac/permissions.constants.ts:78-80(feature-flags.read/update/delete). Important: the permissions exist inALL_PERMISSIONSbut are not enforced on any flag endpoint today —RbacGuardallows when no@Roles()/@Permissions()metadata is present (rbac.guard.ts:29) and the controller declares onlyJwtAuthGuard(feature-flags.controller.ts:18). So every persona below with a valid JWT can mutate flags; the UI must be built as if permissions will be enforced (OQ-5).
1. Org Admin (org_admin) — owner of the flag catalog
| Attribute | Value |
|---|---|
| Frequency | Weekly-ish; during onboarding and channel setup |
| Permissions | ALL_PERMISSIONS incl. feature-flags.* (role.schema.ts:23 → seeded org_admin) |
| Goal | See what capabilities their institution has on/off; flip channels (biometric, SMS, WhatsApp) on demand |
| Pain points | Doesn't know which module a key belongs to; enabling a flag but the feature "not working" (propagation delay, OQ-1) |
| Core job | GET /feature-flags (browse), PUT /feature-flags (toggle one), PUT /feature-flags/bulk (rollout) |
| Restrictions | Writes are not permission-gated today (OQ-5) — but per contract they will be; UI hides write affordances without feature-flags.update |
| Expected behaviour | Instant toggle feedback; clear "applies to which module" grouping; honest "why can't I see X" messages for end users |
| Mental model | "This is my school's switchboard — flip what we paid for / bought hardware for." |
2. Feature Configurator (admin delegate with feature-flags.update)
| Attribute | Value |
|---|---|
| Frequency | During rollouts and migrations; support tickets |
| Permissions | feature-flags.read, feature-flags.update (custom role; possible today in RBAC config) |
| Goal | Batch-configure flags (e.g., enable SMS for all campuses), fix label/description typos, stage a module's rollout |
| Core job | PUT /feature-flags/bulk (feature-flags.controller.ts:48-52), editor with key/enabled/label/description/module (update-feature-flag.dto.ts:4-26) |
| Pain points | Bulk is sequential, no transaction (feature-flags.service.ts:40-44) → needs per-row result; editor's description/module are silently dropped by the server upsert (feature-flag.repository.ts:40) |
| Restrictions | No feature-flags.delete → delete affordance hidden (server would accept it anyway — OQ-5) |
| Expected behaviour | See per-flag success/failure after bulk; never "saved" when the server persisted only 2 of 5 fields |
| Mental model | "I configure the switches; the platform team owns the keys." |
3. End Users Affected by Flags (teacher, staff, parent, student)
| Attribute | Value |
|---|---|
| Frequency | Every session — flags gate the app they see |
| Permissions | Usually none of feature-flags.*; low-permission roles (role.schema.ts:31-42) |
| Goal | Never crash, never see a grayed "coming soon" wall; a disabled feature simply isn't there |
| Core interaction | None direct — the client gates UI via FeatureFlagsCubit (00-shared/06 §4: "gates UI per tenant (biometric, SMS, WhatsApp channels)") |
| Pain points | A flag flips mid-session (admin toggles SMS off while teacher is in the SMS-send flow) |
| Expected behaviour | Feature disappears/re-enables within propagation window (client TTL; server 30 s cache (planned) CACHE_ARCHITECTURE.md:48); in-flight action completes gracefully |
| Mental model | "The app just has what my school has." |
4. Platform Admin (platform_admin, cross-tenant support)
| Attribute | Value |
|---|---|
| Permissions | isPlatformAdmin=true when role present (jwt-auth.guard.ts:54); reads bypass tenant scope (base.repository.ts:21-23) |
| Frequency | On-demand support / diagnostics / platform-level rollouts |
| Goal | Inspect a tenant's flag state during support; evaluate platform-level defaults |
| Restrictions | Upsert calls requireTenantId() directly (feature-flag.repository.ts:37) — platform-admin write without a tenant context throws; use org-level surface instead (PATCH /organizations/:id/feature-flags, organizations.controller.ts:81-89) |
| Expected behaviour | Read any tenant's flags; write via the org lifecycle surface with organization.update semantics |
| Mental model | "Support reads; tenant ops writes." |
5. Downstream Module Consumers (biometric, notifications, payments) — (forward-looking)
| Attribute | Value |
|---|---|
| Role | Services, not users — they call FeatureFlagsService.isEnabled(key) (feature-flags.service.ts:22-25) |
| Contract | isEnabled returns flag?.enabled ?? false — missing flag = disabled (fail-closed) |
| Status | No consumer in src/ today (grep: only the flag module itself); PLAN.md:29 scenario 2.6 documents the intent ("Feature-toggle read in downstream services") |
| Example keys (from handbook) | "Biometric Attendance", "WhatsApp", "AI Reports", "Payroll", "Inventory" (COLLECTIONS.md:791-800); WhatsApp channel is Phase 2 (FEATURE_ROADMAP.md:27) |
| Client impact | These modules' screens render behind flags the admin manages here — this package specs the gating mechanism (13_State_Management.md) |
6. Role → flag-appearance matrix
| Value | org_admin | configurator | end user | platform_admin |
|---|---|---|---|---|
| Flags list (read) | ✓ | ✓ | — (app is pre-gated for them) | ✓ (cross-tenant) |
| Flag editor | ✓ | ✓ | — | ✓ (org surface) |
| Bulk update | ✓ | ✓ | — | — |
| Delete flag | ✓ (server accepts; UI per policy) | — (no perm) | — | — |
| Org overlay flags | via /organization settings | — | — | ✓ |
| Toggle visibility to end users | n/a — flags gate THEIR screens, not the admin's |
Roles/goal frequency: shared baseline 00-shared/01. Every persona's flag-facing need collapses to: admin — "flip it and know it applied"; platform — "read it fast"; end user — "never see what's off." The module's differentiators (module grouping, enabled-only set, bulk, per-row results) are the admin-facing surface.
03 — User Journeys (Feature Flags Module)
- 1. Browse flags
- 2. Toggle a flag (single)
- 3. Bulk update (rollout)
- 4. Disabled-feature experience (end user)
- 5. Flag rollback (revert a mistake)
End-to-end journeys computed from
feature-flags.controller.ts+feature-flags.service.ts
organizations.service.ts. Each journey: entry, intent, decision points, system responses, loading, failures, recovery, exit.(planned)/(forward-looking)per global rules.
1. Browse flags
entry: Settings → Feature Flags (admin); deep link studylyon://settings/feature-flags
intent: see the tenant's toggle catalog, grouped, with on/off status
sequenceDiagram
actor U as Org Admin
participant F as FlagsListScreen
participant R as FeatureFlagsRepository
participant API as GET /feature-flags
U->>F: open screen
F->>R: load()
R->>API: (Bearer JWT) no query
alt 200
API-->>R: data[] sorted by key asc (feature-flags.service.ts:11)
R-->>F: group by module (client-side)
F-->>U: groups with switch rows + counts
else 401 UNAUTHENTICATED
API-->>R: → silent refresh; fail → sessionExpired
else 5xx
F-->>U: AppErrorState + Retry
end
U->>F: tap module filter chip
F->>R: load(module) → GET /feature-flags?module=X (feature-flags.controller.ts:25-27)
- Decision points: module filter vs all; "Enabled only" toggle →
GET /feature-flags/enabled(feature-flags.controller.ts:30-34) — the same set the gating cubit uses. - Loading:
AppSkeleton(list); cached last-good shown while revalidating (00-shared/06 §3.3). - Failure covers: 401 (refresh flow), 5xx (generic + requestId), offline (cached + banner).
- Exit: tap row → flag detail; back → settings hub.
- Empty state: fresh tenant has no flags (no seeding in code) →
AppEmptyState"No feature flags yet — create one or contact your platform team."
2. Toggle a flag (single)
entry: flags list switch, or detail screen action
intent: change enabled state for one key
sequenceDiagram
actor U as Configurator
participant L as FlagsListScreen
participant R as FeatureFlagsRepository
participant API as PUT /feature-flags
U->>L: flip switch "channels.whatsapp"
L->>L: optimistic: switch on + row "saving"
L->>R: upsert({key, enabled:true, label})
R->>API: body {key, enabled, label?}
alt 200
API-->>R: flag doc (server truth)
R-->>L: reconcile row (server doc)
L-->>U: snackbar "Feature enabled" + lightImpact
else 404 RESOURCE_NOT_FOUND
API-->>R: (n/a for upsert — upsert creates)
else 400 VALIDATION_ERROR
R-->>L: rollback switch + inline error (missing key/enabled, update-feature-flag.dto.ts:5-11)
else 5xx (incl. E11000 duplicate on re-create, OQ-4)
R-->>L: rollback switch + snackbar error + requestId
end
- Optimistic per
00-shared/06 §3.5: toggles are safe mutations → apply locally, rollback on error. Reconcile with server payload on success. - Critical semantics (server): upsert
$sets onlyenabledandlabel(feature-flag.repository.ts:40) — the editor'sdescription/moduleare not persisted on update (OQ-3). The client must not claim "saved" for those fields. - Missing key +
enabledrequired:PUT /feature-flagswith{key, enabled:false}is how you create a flag disabled-by-default (update-feature-flag.dto.ts:5-11). - Propagation: no event, no cache today → server truth immediately; client gating cubit
refreshes per its own TTL (30 s server cache
(planned)CACHE_ARCHITECTURE.md:48).
3. Bulk update (rollout)
entry: flags list → "Bulk update" (configurator with feature-flags.update)
intent: apply a state across many flags (e.g., enable SMS for all channels at once)
sequenceDiagram
actor U as Configurator
participant B as BulkSheet
participant R as FeatureFlagsRepository
participant API as PUT /feature-flags/bulk
U->>B: multi-select rows → set on/off
B->>R: bulkUpdate([{key,enabled,label?}...])
R->>API: body = array of UpdateFeatureFlagDto
alt 200
API-->>R: data[] = server docs, **sequential** (feature-flags.service.ts:40-44)
B-->>U: per-row results: n applied, m failed (client compares keys)
else 400 VALIDATION_ERROR
B-->>U: field errors on offending rows; nothing applied if first item fails at validation
else 5xx mid-way
B-->>U: partial state shown (earlier items applied, later not) — server returns error envelope, no rollback
end
- Decision points: none server-side — array validated as a whole by class-validator; each
item needs
key+enabled(update-feature-flag.dto.ts). - Failure covers: partial application (documented, OQ-3) → UI must show exactly which keys applied; retry applies only the failed subset (client tracks).
- No transaction, no idempotency header support confirmed (
00-shared/12 B6) — the client does not auto-retry a failed bulk (would re-apply successes as no-ops — safe, since upsert is idempotent per key, but noisy).
4. Disabled-feature experience (end user)
entry: any gated module screen (biometric check-in, SMS/WhatsApp compose, payments channel)
intent: user hits a feature their tenant has switched off
flowchart TD
U[User opens app / module] --> C{FeatureFlagsCubit\nhas key?}
C -- enabled --> S[Feature UI renders\nnormal flow]
C -- disabled / missing --> H[Feature hidden or\nAppEmptyState/notice]
C -- offline / stale --> L[Last-good set used\n+ offline banner]
C -- in-flight action when flag flips --> R[Complete current action;\nhide entry points after]
H --> O[Optional: 'Ask your admin\nto enable <feature>' - proposed copy]
- Gating data source:
GET /feature-flags/enabled(feature-flags.controller.ts:30-34) → client buildsSet<key>;isEnabled('x.y')is a sync lookup inFeatureFlagsCubit(13_State_Management.md). - Missing flag = disabled: server semantics
flag?.enabled ?? false(feature-flags.service.ts:24) — client mirrors fail-closed. - Failure covers: stale cache (TTL window), offline (last-good), mid-session flip (next refresh boundary). The admin journeys above are the flip side of this journey — no dedicated server endpoint, all derived from the enabled set.
5. Flag rollback (revert a mistake)
entry: flag detail → "Disable", or list swipe → disable; delete → re-create (edge)
intent: undo a bad enablement quickly
sequenceDiagram
actor U as Configurator
participant D as FlagDetailScreen
participant R as FeatureFlagsRepository
participant API as PUT /feature-flags | DELETE /feature-flags/:key
U->>D: toggle off (or delete from menu)
D->>R: upsert({key, enabled:false, label}) OR remove(key)
alt toggle off (rollback path A)
API-->>D: 200 flag doc enabled:false — instant rollback
else delete (rollback path B)
API-->>D: 200 (void) — soft-delete (feature-flags.service.ts:47-52)
Note over D: flag disappears from all lists (scopedFilter isDeleted:false, base.repository.ts:20-30)
U->>D: "re-create" same key → PUT upsert
alt re-create works (no tombstone conflict)
API-->>D: 200 new doc
else E11000 duplicate key (soft-deleted doc still occupies unique {tenantId,key} index, feature-flag.schema.ts:26)
API-->>D: 500 INTERNAL_SERVER_ERROR — no recovery path in API (OQ-4)
end
end
- Recommendation: rollback = toggle off (path A), never delete — deletion is destructive to re-creation today (OQ-4).
- Second 404:
remove()callsfindByKeyfirst (feature-flags.service.ts:47-52) — the first 404 covers missing key; the second guards softDelete failure. - Exit: snackbar + row leaves list (delete) or switches state (toggle).
Abandonment & exit rules (all): back = return to flags list without persisting sheet edits; timeout = none server-side; permission denial = none today (OQ-5) but UI routes read/write affordances by permission; offline = list reads from cache, writes blocked with banner (no offline write queue defined for flags).
04 — Information Architecture (Feature Flags Module)
- 1. Placement in the shell
- 2. Route table (module-owned; extends
00-shared/05 §4) - 3. Screen hierarchy
- 4. Modal hierarchy (module)
- 5. Screen relationships
- 6. Nested nav
- 7. Quick actions / FAB / context menus
- 8. Deep links (proposed)
- 9. Accessibility of IA
- 10. Peripheral: what the module owns vs the shell
Where the Feature Flags screens live in the shell (00-shared/05). The module owns a Settings sub-section (
/settings/feature-flags, admin-only) plus the global gating service that influences every role's surface (rendered elsewhere; owned byFeatureFlagsCubit, see 13). Everything is derived fromfeature-flags.controller.ts(routes) and the shared shell.
1. Placement in the shell
App
└─ AppShell (role-aware)
├─ NavigationBar / Rail / Drawer (00-shared/05 §2)
└─ /settings [admin]
└─ Settings hub
└─ /settings/feature-flags Feature Flags (this module)
├─ /settings/feature-flags list (all / by module / enabled)
├─ /settings/feature-flags/:key flag detail
├─ /settings/feature-flags/:key/edit flag editor (full-screen, >3 fields)
└─ (sheet) bulk update
└─ global: FeatureFlagsCubit gates routes & widgets across all modules
(biometric, notifications/channels, payments — (forward-looking) consumers)
Settingsis an existing top-level destination (00-shared/05 §2: "Settings | /settings | admin"), so the flags section is a settings sub-page — same pattern as Auth's Security hub under/settings(auth/04 §1).- The route prefix is module-owned and proposed (no client exists;
00-shared/12 D1/A2):/settings/feature-flags. Alternative(proposed): nest under/settings/integrationsonce theintegrationsmodule lands (blueprint lists it separately,COLLECTIONS.md:805-818).
2. Route table (module-owned; extends 00-shared/05 §4)
| Route | Screen | Auth | Perm (target) | Source API |
|---|---|---|---|---|
/settings/feature-flags | Flags list | JWT | feature-flags.read | GET /feature-flags (+?module=) |
/settings/feature-flags?tab=enabled | Enabled-only list | JWT | feature-flags.read | GET /feature-flags/enabled |
/settings/feature-flags/:key | Flag detail | JWT | feature-flags.read | GET /feature-flags/:key |
/settings/feature-flags/:key/edit | Flag editor | JWT | feature-flags.update | PUT /feature-flags |
/settings/feature-flags/bulk | Bulk update (sheet) | JWT | feature-flags.update | PUT /feature-flags/bulk |
| (menu) delete | — | JWT | feature-flags.delete (target) | DELETE /feature-flags/:key |
Guard note: permissions are not enforced server-side today (
rbac.guard.ts:29,feature-flags.controller.ts:18) — the routes are spec'd as if they will be (OQ-5).
3. Screen hierarchy
- Flags list is the hub: rows → detail; module filter chips + enabled tab; FAB / bulk button; row menu (edit, delete).
- Detail → Edit (full-screen page — 5 fields incl. description;
> 3 fields→ page per00-shared/05 §5); Edit returns → Detail (refresh). - Bulk update is a bottom sheet over the list (selection state lives in the list cubit).
flowchart TD
SET[/settings/] --> FF[/settings/feature-flags/]
FF --> ENABLED[/settings/feature-flags?tab=enabled/]
FF --> DET[/settings/feature-flags/:key/]
FF --> BULK(("bulk sheet"))
DET --> EDT[/settings/feature-flags/:key/edit/]
EDT --> DET
DET --> FF
BULK --> FF
FF -. gates .-> OTHER[every gated module screen via FeatureFlagsCubit]
4. Modal hierarchy (module)
Following 00-shared/05 §5:
- Snackbar/toast — "Feature enabled", "3 of 5 flags updated", "Flag deleted", copy.
- Bottom sheet — bulk update (selection + apply), key quick-copy.
- Dialog — delete confirm (destructive; typed confirm not required — soft delete is reversible at DB level but re-creation is blocked today, OQ-4 → use standard destructive confirm with the re-create caveat in copy).
- Full-screen page — flag editor (5 fields), flags list on phone.
- Loader — button spinners + list skeletons; never full-screen dual column.
5. Screen relationships
- Flags list → detail returns with changed state (toggle flipped) — detail cubit refreshes
on return; list refreshes on detail pop (
RefreshIndicator+ cubitRefresh). - Bulk sheet applies → list refetches → per-row result banner.
- Delete → row removed (server-confirm, no optimistic removal for delete; toggle IS optimistic).
- Global: whenever
FeatureFlagsCubitrefreshes (app.resume, pull-to-refresh, TTL), every gated widget rebuilds viaBlocBuilder/BlocSelector— no route change needed.
6. Nested nav
- Tablet/desktop ≥ 840 dp: master-detail — list left pane, detail right (
00-shared/04 §6). - Phone: push (list → detail → edit).
- The bulk sheet keeps list selection state in
FlagsListCubit(sheet is a view of it).
7. Quick actions / FAB / context menus
- FAB (extended): "New flag" — opens the flag editor in create mode (upsert with a new
key). One FAB per screen (
00-shared/03 D). - Row
AppMenu: Edit, Delete (destructive). - Module filter chips + "Enabled only"
FilterChiprow under the AppBar. - Keyboard (desktop):
Ctrl+Ksearch n/a (list is short);Nnew flag;Esccloses sheet; space toggles focused switch (00-shared/08 §2).
8. Deep links (proposed)
| Pattern | Target | Note |
|---|---|---|
studylyon://settings/feature-flags | Flags list | admin |
studylyon://settings/feature-flags/:key | Flag detail | supports support-links |
studylyon://settings/feature-flags?module=biometric | Filtered list | prefill filter |
9. Accessibility of IA
- One
headlineMediumper screen; list rows ordered: name → module badge → state switch. - Focus begins on first filter chip (desktop); switch rows announce state
(
Semantics(toggled:),09_Accessibility_Baseline.md §5). - Gated screens: hidden content is removed from the semantics tree, never "disabled-but- announced" (screen readers must not read what the tenant can't use).
10. Peripheral: what the module owns vs the shell
- Shell-owned: Settings hub entry, global 403/404 screens, offline banner.
- Module-owned: the four flag screens + the bulk sheet + the global gating contract (FeatureFlagsCubit) that other modules consume — gating widgets live in shared, driven by this module's state (13_State_Management.md).
05 — Screen Inventory (Feature Flags Module)
- 1. Flags List (
/settings/feature-flags) - 2. Flag Detail (
/settings/feature-flags/:key) - 3. Flag Editor (
/settings/feature-flags/:key/edit) — create + edit modes - 4. Bulk Update (bottom sheet over list)
- 5. Affected-Features Map (detail companion) —
(proposed)client composition - Shared components used
- Analytics events (proposed)
- Keyboard, landscape, tablet, desktop
Every screen of the module: intent, route, composition, states, permissions, platform behavior. Authoritative components in 00-shared/03; module-specific widgets in 07_Component_Library.md.
Legend — States: idle / loading / success / empty / error(offline, 401) / disabled / permission. Analytics events follow
{module}.{screen}.{action}(proposed; SDK open — 00-shared/10 §8).
1. Flags List (/settings/feature-flags)
| Field | Detail |
|---|---|
| Purpose | Browse tenant toggle catalog; grouped by module; quick-toggle; entry to detail/bulk |
| Entry | Settings hub; deep link with ?module= prefilter |
| Exit | row → detail; bulk sheet; back → settings |
| Source | GET /feature-flags (all, sorted key asc — feature-flags.service.ts:11); GET /feature-flags?module= (feature-flags.controller.ts:25-27); tab → GET /feature-flags/enabled (feature-flags.controller.ts:30-34) |
| Composition | AppSearchBar(optional, client-side), module FilterChips (distinct module values), "Enabled only" toggle tab, grouped list (AppSectionHeader per module), row = AppFlagRow |
| States | loading (AppSkeleton(list)); empty (AppEmptyState — fresh tenant); error (AppErrorState); offline (cached + banner); permission (no feature-flags.read → route guarded, 403 screen) |
| Row actions | switch (optimistic toggle, PUT /feature-flags), AppMenu: Edit, Delete |
| Bulk CTA | AppButton "Bulk update" (enabled when ≥ 1 selection; selection mode via long-press or checkbox toggle) |
| Analytics | featureflags.list.view, featureflags.list.filter{module}, featureflags.toggle{key,value} (proposed) |
| Adaptive | phone: list full width, selection via long-press; tablet/desktop: master-detail at ≥ 840 px, hover row highlight |
| a11y | each row Semantics(button, toggled:); module headers announced; filter chips with selected state |
| Motion | m-entrance stagger on rows; switch m-fast |
2. Flag Detail (/settings/feature-flags/:key)
| Field | Detail |
|---|---|
| Purpose | Full picture of one flag + primary toggle |
| Entry | list row tap; deep link |
| Exit | edit; delete; back to list |
| Source | GET /feature-flags/:key → doc; 404 if key missing (feature-flags.service.ts:27-31) |
| Composition | header (key mono, module AppBadge, enabled AppSwitch), AppInfoRows: label, description (or "—"), timestamps createdAt/updatedAt (BaseSchema timestamps:true, base.schema.ts:33-34), tenant scoping note; edit AppButton; delete AppButton (destructive, dialog) |
| States | loading (skeleton); 404 → AppEmptyState "Flag not found" (deleted/renamed); error; offline |
| Permissions | read → view; update → toggle/edit enabled; delete → delete (target contract, OQ-5) |
| Analytics | featureflags.detail.view, featureflags.detail.toggle{key,value} (proposed) |
| a11y | switch semantics; key read as mono code; description in body text |
| Motion | hero from list row (m-slow) — optional; toggle m-fast |
3. Flag Editor (/settings/feature-flags/:key/edit) — create + edit modes
| Field | Detail |
|---|---|
| Purpose | Edit/create one flag: key, enabled, label, description, module |
| Entry | detail "Edit"; FAB "New flag" (create mode — key empty) |
| Exit | Save → detail (refresh); discard → back |
| Source | Save = PUT /feature-flags (upsert — create-or-update, feature-flags.controller.ts:42-46); in create mode a missing key creates a new flag |
| Composition | form fields per 08_Form_Specifications.md; save AppButton (loading) |
| States | edit mode pre-fills from detail doc; create mode empty; saving; validation errors inline; warning banner: description/module are not persisted by the server upsert (feature-flag.repository.ts:40) — see OQ-3 |
| Analytics | featureflags.edit.save{key}, featureflags.create.save{key} (proposed) |
| a11y | labels linked, focus first field, error announce |
| Adaptive | full-screen page (5 fields); tablet centered column ≤ 560 px |
| Motion | m-base push; save success → check + pop |
4. Bulk Update (bottom sheet over list)
| Field | Detail |
|---|---|
| Purpose | Apply a target state to N selected flags |
| Entry | list "Bulk update" (≥ 1 selected) |
| Source | PUT /feature-flags/bulk — body array of UpdateFeatureFlagDto (feature-flags.controller.ts:48-52) |
| Composition | header "Update N flags", mode toggle (enable all / disable all), optional label edit (single field applied to all — (proposed); server only persists enabled+label, feature-flag.repository.ts:40), apply AppButton |
| Result | per-row outcome list (success/fail) — server is sequential, no transaction (feature-flags.service.ts:40-44); failed subset can be retried |
| States | idle (count N), applying (progress), done (per-row results), error (partial) |
| Analytics | featureflags.bulk.apply{n,target}, featureflags.bulk.result{n_ok,n_fail} (proposed) |
| a11y | sheet focus trap; progress announced (live region) |
| Motion | m-base sheet; rows fade-in stagger |
5. Affected-Features Map (detail companion) — (proposed) client composition
| Field | Detail |
|---|---|
| Purpose | Show the end-user surface a flag gates ("turning this off hides: Biometric check-in") |
| Entry | detail screen "Affects" section / tab |
| Source | No backend endpoint — the client composes this from: flag module field, the client's own gated-widget registry (FeatureFlagsCubit keys → screens), and (planned) IMPLEMENTATION_PLAN.md:310-336 matrices. Purely a client-side map; mark results with the module badge. |
| States | empty ("no client screens mapped to this key yet") — key is not in the registry |
| Note | Must not claim server authority; render as informational, (proposed) — hidden when no client registrations exist |
Shared components used
AppSkeleton, AppEmptyState, AppErrorState, AppOfflineBanner, AppSnackbar,
AppBanner, AppSwitch, AppChips (FilterChip), AppListTile, AppCard, AppButton,
AppDialog, AppBottomSheet, AppMenu, AppSectionHeader, AppBadge, AppFAB,
AppInfoRow, AppSearchBar.
Module-specific: AppFlagRow, AppFlagGroupHeader, AppFlagEditorForm, AppBulkUpdateSheet,
AppFlagKeyChip, AppAffectedMapCard — defined in 07_Component_Library.md.
Analytics events (proposed)
featureflags.list.{view,filter,toggle}, featureflags.detail.{view,toggle},
featureflags.edit.{save,fail}, featureflags.create.{save,fail},
featureflags.bulk.{apply,result}, featureflags.delete.{confirm,cancelled},
featureflags.gate.{blocked,shown} (gate events from FeatureFlagsCubit, proposed).
Keyboard, landscape, tablet, desktop
- List: phone long-press → selection; desktop hover + right-click menu;
Nnew. - Editor:
Tabkey→enabled→label→description→module→save;Entersubmits;Escdiscards. - Bulk sheet: arrow keys navigate rows;
Entertoggles selection. - All lists
ListView.builder; keyboards avoid insets on phone.
06 — Screen Specifications (Feature Flags Module)
- 1. Flags List Screen
/settings/feature-flags - 2. Flag Detail Screen
/settings/feature-flags/:key - 3. Flag Editor Screen
/settings/feature-flags/:key/edit - 4. Bulk Update Sheet
- 5. Affected-Features Map (detail companion) —
(proposed)
Production-grade specs: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens:
Sp= 00-shared/02 §3,Ty= §2,El= §5,R= §4,Mo= §6. All components reference 00-shared/03.
1. Flags List Screen /settings/feature-flags
Chrome: standard AppBar (surface, El-1) with headlineMedium title "Feature flags";
subtitle count "N toggles · M enabled". Filter row under AppBar (sticky): module FilterChips
(sourced from the loaded data's distinct module values; when none, show "All modules" chip
only) + "Enabled only" toggle (FilterChip). Body: grouped list.
AppBar: Feature flags [menu]
FilterChip: All | biometric | channels | academics | …
FilterChip: [x] Enabled only
[SectionHeader] BIOMETRIC (count)
[AppFlagRow] biometric.enabled ON [switch]
[AppFlagRow] biometric.fingerprint OFF [switch]
[SectionHeader] CHANNELS
[AppFlagRow] channels.sms ON [switch]
[AppFlagRow] channels.whatsapp OFF [switch]
[FAB extended] New flag
Data contract (list source):
- Default:
GET /feature-flags→ array sorted bykeyasc (feature-flags.service.ts:10-12) — not paginated (plain array; nometa). - Module chip:
GET /feature-flags?module=<m>→findByModule(feature-flags.controller.ts:25-27,feature-flag.repository.ts:24-26). - Enabled tab:
GET /feature-flags/enabled→{enabled:true}filter (feature-flag.repository.ts:28-30).
Toggle semantics (the switch):
- Optimistic apply (
00-shared/06 §3.5): on flip → row shows pending state (switch on, row "saving…" label,AppProgressinline) →PUT /feature-flags {key, enabled:newValue, label:<current label>}. - Server truth:
findOneAndUpdate({tenantId,key,isDeleted:false}, {$set:{enabled,label}}, {upsert:true,new:true})(feature-flag.repository.ts:38-42). On 200 → reconcile row with returned doc. On error → rollback switch +AppSnackbar(error); 400 field error shown under row. - Disabled switch state: a switch is disabled when (a) another request in flight on the
same row, (b) offline (writes blocked — no offline queue for flags,
00-shared/07 §10), (c) nofeature-flags.updatepermission (target contract, OQ-5), (d) delete pending. - The switch reflects server doc only after reconcile — the row never claims a state the server didn't confirm.
Propagation timing (what the admin must be told):
- Server has no cache and no event today — the toggle is effective on the next read of
the flag collection; downstream
isEnabled()calls (feature-flags.service.ts:22-25) see it immediately. - Blueprint target: flags cached in Redis
sl:global:featureflag:{name}with poll + cache, 30 s TTL (CACHE_ARCHITECTURE.md:33,48) — once implemented, a toggle can take up to 30 s to be visible to all services. UI copy: after a successful toggle show a subtleAppBanner(info): "Applies to apps within ~30 seconds" only when the cache lands; today it is immediate — keep the banner(planned)-flagged in code (feature toggle for copy,(proposed)). - Client side:
FeatureFlagsCubitTTL (default 30 s,(proposed), mirrorsCACHE_ARCHITECTURE.md:48) — the app re-reads the enabled set at that cadence; aRefreshIndicatorforces an immediate re-read.
Cache-rebuild awareness:
- The
cache-rebuildBullMQ queue exists (queue.constants.ts:13) and is wired to a scheduler (scheduler.service.ts:27,67,158,214) but nothing enqueues it from flag writes — no invalidation path exists in code. UI implication: never show "cache cleared" or "propagated"; the list/detail screens always read the collection directly (server source of truth). When the flag cache lands, the invalidation trigger is(planned)(CACHE_ARCHITECTURE.md §4— permission/dashboard use event-based invalidation; flag writes should follow the same pattern, OQ-7).
States:
- loading →
AppSkeleton(list)(≤ 200 ms budget,00-shared/10 §1). - empty →
AppEmptyState: "No feature flags yet" — fresh tenants have no seeded flags (no seed code in repo); body: "Flags appear here once created — ask your platform team or create one." CTAFilledButton.tonal"New flag" (ifupdate). - error →
AppErrorState(code)with Retry; 401 → silent refresh flow. - offline →
AppOfflineBanner+ last-good cache (client cache keysl:cache:featureflags:all(proposed)); switches disabled with tooltip.
A11y: filter chips Semantics(selected:); each row = one Semantics(button, toggled:, label: "flag <key>"); toggle announced via live region; group headers Semantics(headers:).
Motion: m-entrance stagger 40 ms rows; switch m-fast; filter change re-fade m-slow
(reduced-motion = instant fades).
Responsive: phone single pane; tablet/desktop master-detail (list left ~360 px, detail
right); hover row tint m-fast; Tab order chips → rows → FAB.
2. Flag Detail Screen /settings/feature-flags/:key
[Back] headlineMedium "Flag detail"
AppCard:
key (mono, copy icon) AppFlagKeyChip
module badge (AppBadge) created/updated (bodySmall, tabular figures)
AppSwitch "Enabled" [ON] ← primary toggle (same optimistic semantics as list)
AppInfoRow label: <label or "—">
AppInfoRow description: <description or "—">
AppSectionHeader "Affects"
AppAffectedMapCard (proposed — client registry, may be empty)
[outlined] Edit [text, destructive] Delete
Source: GET /feature-flags/:key → 404 RESOURCE_NOT_FOUND ("Feature flag "feature-flags.service.ts:29) when missing (soft-deleted flags are invisible to
the scoped read too, base.repository.ts:20-30).
States: skeleton → doc; 404 → AppEmptyState "Flag not found — it may have been
deleted" + back; offline → cached doc + banner (writes blocked).
Toggle: same PUT /feature-flags optimistic flow; on success reconcile + snackbar
"Feature
Delete flow (destructive):
AppDialog "Delete this flag?" body: "It will stop appearing everywhere. Re-creating the
same key may fail (the key slot is retained) — prefer disabling instead." → DELETE /feature-flags/:key (feature-flags.controller.ts:54-58). Server: soft delete by _id
(feature-flags.service.ts:47-52); double 404 protected. On 200 → pop to list + snackbar.
On 500 E11000-style duplicate → OQ-4 copy "Couldn't delete — try again". No optimistic
removal (destructive, server-confirm).
A11y: key as code text; switch semantics; dialog focus trap; delete = error colors.
Motion: hero list→detail m-slow (optional); delete dialog m-base; row removal fade.
3. Flag Editor Screen /settings/feature-flags/:key/edit
Chrome: full-screen page (5 fields — 00-shared/05 §5), AppBar with save action.
AppBar: "Edit flag" | "Create flag" [Save]
AppFlagEditorForm:
key AppTextField (mono) — read-only in edit mode; editable in create mode
enabled AppSwitch "Enabled" — required boolean
label AppTextField (optional)
description AppTextField (multiline, optional)
module AppTextField (optional, suggests existing modules)
[AppBanner warning — edit mode]: "Note: description & module are not saved by the server on
update — only enabled & label persist (feature-flag.repository.ts:40)." (OQ-3)
Semantics (server-exact): UpdateFeatureFlagDto = key (string, required), enabled
(boolean, required), label?, description?, module? (update-feature-flag.dto.ts:4-26).
Save = PUT /feature-flags (upsert, feature-flags.controller.ts:42-46). The repository
drops description and module on write (feature-flag.repository.ts:40) — the editor
must (a) show the warning banner above, (b) never render those two fields as "saved" after a
successful response. The module field is written only at first creation of the key
(upsert insert path writes the whole DTO? — no: the insert path writes only what the
repository passes; since the repository only passes enabled/label, even the insert
writes only those three (tenantId,key,enabled,label); description/module are effectively
unwritable through the API today — OQ-3). Client must therefore treat description/module
as read-only display (they can only be populated by seed/migration (planned)
IMPLEMENTATION_PLAN.md:291-307).
Create mode: key empty + placeholder "e.g. channels.whatsapp"; server accepts any string
(no pattern validation, update-feature-flag.dto.ts:5-7); enabling a brand-new key creates
the flag. After save in create mode → navigate to detail of returned doc (server returns the
new doc, {new:true}, feature-flag.repository.ts:41).
States: idle / saving (button spinner, double-submit blocked) / field errors (400
VALIDATION_ERROR per-field details, http-exception.filter.ts:103-107) / success (pop +
snackbar) / offline (writes blocked banner).
Keyboard: key → enabled(switch via Space) → label → description → module; Enter
submits; Esc discards (confirm discard dialog if dirty).
A11y: labels linked; error focus jump to first invalid; warning banner announced once.
4. Bulk Update Sheet
[Title] "Update 3 flags" [drag handle]
mode SegmentedButton: [Enable all] [Disable all]
AppTextField label (optional, applied to all — note server persists only enabled+label)
[Apply] FilledButton (loading while applying)
result: per-key rows ✓ / ✕ (AppFlagKeyChip + reason)
"Retry failed (1)" text button — resends only failed subset (client-tracked)
Semantics (server-exact): body = JSON array of DTOs (feature-flags.controller.ts:50);
service loops await repo.upsert(...) sequentially — no transaction, no rollback
(feature-flags.service.ts:40-44). Class-validator rejects the whole body 400 if any item
is invalid → sheet shows which row is invalid (map details[].message to key). A 5xx
mid-loop → error envelope; client shows applied-so-far vs remaining. No auto-retry of the
full batch (upsert is idempotent per key, so a manual retry is safe but noisy — keep explicit).
Propagation note: same as single toggle — server truth immediate today; 30 s window
when the Redis cache lands (CACHE_ARCHITECTURE.md:48).
States: idle (count) → applying (progress bar, m-base) → done (per-row results,
success secondary, failure error) → error (partial or 400). a11y: live region announces
"3 of 5 applied".
5. Affected-Features Map (detail companion) — (proposed)
AppAffectedMapCard: lists client screens registered against this key in the gating registry
(FeatureFlagsCubit.registry), each row: icon, screen name, "hidden when off". Empty state:
"Nothing in the app maps to this key yet". No server call — pure client composition from
module + registry; (proposed) because no client exists and the server has no
"affected features" endpoint.
Cross-screen micro-interaction & motion notes:
Mo.m-baseroute transitions; cardsEl-e1; sheete-5r-xl.- Snackbar durations: success < 3 s, error ≥ 4 s (
00-shared/03 A). - Switches everywhere:
Mo.m-fast; error rollback =AnimatedSwitcherfade +errorring, no shake on toggle (shake reserved for forms). - Safe areas: bottom-nav height respected; sheets respect insets.
07 — Component Library (Feature Flags Module)
- A. Flag representation
- B. Editor & bulk
- C. Affected-features map
- D. Status & feedback
- E. Only elements the API supports
- F. Flutter widget map (module)
- G. Golden tests (base 00-shared/03 G)
Module-specific components built from shared
App*primitives (00-shared/03). Reuse over new — only components absent from the shared library are defined here.
A. Flag representation
AppFlagRow
- Purpose: one flag in the list (switch + identity + menu).
- Props:
key,label?,enabled,module?,toggleEnabled(bool — offline/no-perm),onToggle(bool),pending(bool — write in flight),onEdit,onDelete,selectable,selected. - Rendering:
AppListTileheight 56; leadingAppFlagKeyChip-ish iconIcons.toggle_on/off(orIcons.flag); title =label ?? key; subtitle = keymonobodySmall+ moduleAppBadge; trailingAppSwitch(value=enabled, onChanged=onToggle, disabled= !toggleEnabled || pending). RowAppMenu(Edit / Delete) — menu hidden whenonEdit/onDeletenull. - States: default / selected (checkbox overlay,
primaryContainertint) / pending (switch disabled + row subtitle "Saving…") / disabled-perm (switch grey, tooltip "Ask an admin") / offline (switch disabled, tooltip "You're offline"). - a11y: single semantics node
button, toggled:, label: "<label>, key <key>, module <m>". - Tokens:
surfaceContainerLowrow,R-md,El-e-0; selectedprimaryContainer. - Motion:
m-fastswitch;m-entrancestagger on list insert. - Flutter:
ListTile+Switch+MenuAnchor; selection viaCheckboxoverlay inAnimatedContainer.
AppFlagKeyChip
- Purpose: compact key display with copy.
- Props:
key,copyable(default true). - Rendering:
monotext insurfaceVariantchipR-sm+Icons.content_copy16; copy →Clipboard.setData+ snackbar "Copied" +selectionClickhaptic. - a11y:
Semantics(button, label: "flag key <key>, copy"); copy result live region. - Flutter:
InputChip-styleMaterial+InkWell.
AppFlagGroupHeader
- Purpose: module section header with count.
- Props:
module(label, e.g., "biometric"),count,onToggleEnabled?(module-level "show enabled only" —(proposed)). - Rendering:
AppSectionHeader(titleMedium) +AppBadgecount; Ungrouped bucket for nullmodulevalues (schema allows absentmodule,feature-flag.schema.ts:21-22). - Flutter:
Row+Text+Badge.
B. Editor & bulk
AppFlagEditorForm
- Purpose: the 5-field flag form (create/edit).
- Props:
initial: FlagModel?,createMode,onSave(UpdateFeatureFlagDto),saving,fieldErrors: Map<String,String>,moduleSuggestions: List<String>(from loaded data). - Field spec (exact DTO mirror,
update-feature-flag.dto.ts:4-26):key—AppTextField,mono, required@IsString(); editable only in create mode.enabled—AppSwitch, required boolean.label—AppTextField, optional string.description—AppTextFieldmultiline (2–3 lines), optional.module—AppTextField+AppChipssuggestions, optional.
- Warning banner (edit mode): description/module not persisted by server upsert
(
feature-flag.repository.ts:40) — see 06 §3. - a11y: labels linked; first invalid focus jump; errors announced.
- Flutter:
Form+TextFormFields +SwitchListTile;Autocompletefor module.
AppBulkUpdateSheet
- Purpose: apply one state to N flags.
- Props:
selected: List<FlagModel>,onApply(state, label?),applying,results?: Map<key, success|failure+reason>,onRetryFailed. - Rendering: header +
SegmentedButton(enable/disable) + optional labelAppTextFieldAppButtonapply; results list ofAppFlagRow-lite rows with ✓/✕.
- a11y: focus trap; progress live region.
- Flutter:
showModalBottomSheet+StatefulBuilder/cubit.
C. Affected-features map
AppAffectedMapCard
- Purpose: client-composed "what this flag gates" card (
(proposed)). - Props:
key,entries: List<{screen, icon, state}>(from gating registry),empty. - Rendering:
AppCardwithAppListTilerows — screen name + "hidden when off"AppBadge(outline); empty → "No app screens map to this key yet". - a11y: card
Semantics(header:); rows plain text. - Flutter:
Card+ListTiles.
D. Status & feedback
- AppFlagToggleBanner — post-toggle info banner: "Applies to apps within ~30 seconds"
(planned)(shown only once the Redis 30 s cache lands —CACHE_ARCHITECTURE.md:48; today propagation is immediate). Flagged behind a client constant; removed when server adds invalidation events (OQ-7). - Reuses
AppBanner,AppSnackbar,AppErrorState,AppEmptyState,AppOfflineBannerwith no module changes.
E. Only elements the API supports
- List endpoints return non-paginated arrays — no infinite scroll, no
LoadMore(feature-flags.service.ts:10-20; envelopemetaabsent —response-envelope.interceptor.ts:56-59). - No percentage/audience rollout controls (schema has no such fields,
feature-flag.schema.ts:9-22) — gradual rollout UI is(planned)(OQ-6). - No restore endpoint for soft-deleted flags — the affected map and delete dialog surface the re-create caveat (OQ-4).
F. Flutter widget map (module)
| Component | Flutter widgets |
|---|---|
| AppFlagRow | ListTile + Switch + Checkbox + MenuAnchor |
| AppFlagKeyChip | Material + InkWell + SelectableText |
| AppFlagGroupHeader | Row + Text + Badge |
| AppFlagEditorForm | Form + TextFormField + SwitchListTile + Autocomplete |
| AppBulkUpdateSheet | showModalBottomSheet + SegmentedButton |
| AppAffectedMapCard | Card + ListTiles |
| AppFlagToggleBanner | MaterialBanner |
G. Golden tests (base 00-shared/03 G)
Every module component: golden at phone/tablet/desktop + dark mode; state goldens for
AppFlagRow (on/off/pending/selected/disabled-perm), AppFlagEditorForm (create/edit/error
banner), AppBulkUpdateSheet (idle/applying/results).
08 — Form Specifications (Feature Flags Module)
- 1. Flag Editor (create + edit) —
PUT /feature-flags - 2. Bulk Update Sheet —
PUT /feature-flags/bulk - 3. Delete Flag —
DELETE /feature-flags/:key - 4. Org-level flag map —
PATCH /organizations/:id/feature-flags - Form-level rules (all)
- Client-side error priority
The flag editor and bulk sheet, field-by-field. Validation mirrors class-validator decorators exactly from
src/modules/feature-flags/dto/update-feature-flag.dto.ts; errors follow theVALIDATION_ERROR(400) envelope with per-fielddetails(http-exception.filter.ts:103-107). Client validates inline; server 400 shadows client.
1. Flag Editor (create + edit) — PUT /feature-flags
DTO source: update-feature-flag.dto.ts:4-26.
| # | Field | Label | Required | Type/Keyboard | Validation (server) | Client UX |
|---|---|---|---|---|---|---|
| 1 | key | Key | ✓ | text, mono, lowercase-hinted | @IsString() (update-feature-flag.dto.ts:6-7) | create mode: editable, placeholder "e.g. channels.whatsapp", helper "Unique per school. Use dots to group (module.name)". edit mode: read-only (upsert matches on key; changing it = new flag) |
| 2 | enabled | Enabled | ✓ | AppSwitch boolean | @IsBoolean() (update-feature-flag.dto.ts:10-11) | default OFF in create mode; "off until you flip it" |
| 3 | label | Label | — | AppTextField | @IsOptional() @IsString() (update-feature-flag.dto.ts:14-16) | hint "Human-readable name shown to staff"; used in list row title |
| 4 | description | Description | — | AppTextField multiline (3 rows) | @IsOptional() @IsString() (update-feature-flag.dto.ts:19-21) | not persisted by server upsert — warning banner; read-only-ish behavior (see below) |
| 5 | module | Module | — | AppTextField + suggestion chips | @IsOptional() @IsString() (update-feature-flag.dto.ts:24-26) | suggestions from loaded data; not persisted by server upsert — see below |
Server write behavior (must be surfaced):
FeatureFlagRepository.upsertpersists only{tenantId, key, enabled, label}(feature-flag.repository.ts:37-42) —descriptionandmoduleare accepted by the DTO but dropped on every write, including first insert.- Impact: the editor's description/module fields can never be saved via the API today. The
form shows
AppBanner(warning)in edit mode: "Description & module can't be saved yet — server persists only enabled & label (feature-flag.repository.ts:40)." Save button persists the rest. These fields will become writable when the repository$setis extended (OQ-3); the form is built so enabling them is a one-line change. - A
description/modulepresent in the doc (e.g., seeded later —(planned)IMPLEMENTATION_PLAN.md:291-307) renders read-only in edit mode until then.
Submit → loading → server:
- 200 → server returns the flag doc (
{new:true},feature-flag.repository.ts:41) → pop to detail (refresh) + snackbar "Flag saved". - 400
VALIDATION_ERROR→ maperror.details[].messageto fields (http-exception.filter.ts:103-107); emptydetails→ generic 400 banner. - 404 on edit → flag deleted meanwhile → banner "This flag was removed — reopening list".
- 5xx (incl. E11000 duplicate on re-create after soft-delete, OQ-4) →
AppErrorState-style snackbar with requestId; form retained.
Client-side pre-validation (UX only; server is authority):
- key: non-empty, trimmed; suggest
^[a-z][a-z0-9._-]{1,63}$(proposed)(server accepts any string — no pattern,update-feature-flag.dto.ts:5-7); warn on spaces. - enabled: boolean, no client gate.
- label/description/module: optional, trimmed.
- Double-submit blocked while saving (00-shared/08 §6).
2. Bulk Update Sheet — PUT /feature-flags/bulk
| Field | Label | Required | Validation | UX |
|---|---|---|---|---|
| (selection) | N selected flags | ✓ | ≥ 1 item | multi-select rows in list (long-press / checkbox); count in header |
state | Set to | ✓ | boolean | SegmentedButton [Enable all] [Disable all] |
label (shared) | Label (all) | — | @IsOptional() @IsString() | applied to every selected key; optional |
Body: JSON array of UpdateFeatureFlagDto (feature-flags.controller.ts:50).
Each item: {key, enabled, label?} (only these persist — same repository, same caveat).
Server behavior:
- 400 → whole-body validation failure: class-validator reports array-item errors
(
details[].message) → map each message back to its item by order; highlight offending rows. - 200 → array of server docs, applied sequentially (
feature-flags.service.ts:40-44) — order preserved; client derives per-row success by matching returnedkeys. - 5xx mid-loop → partial apply; error envelope returned; no rollback. Sheet shows "Applied: k of n" and "Retry failed (n−k)" — retry resends only missing keys (client-tracked; safe because upsert is idempotent per key).
Result presentation: rows with ✓ (secondary) / ✕ + reason (error); dismiss →
list refetch (Refresh).
3. Delete Flag — DELETE /feature-flags/:key
No body. Confirm dialog (destructive) → DELETE /feature-flags/:key
(feature-flags.controller.ts:54-58). Server: 404 if key missing (feature-flags.service.ts:47-52)
→ treat as already-gone; 200 → soft delete, row removed. Copy warns about re-create
constraint (OQ-4). Server-confirm only — no optimistic removal.
4. Org-level flag map — PATCH /organizations/:id/feature-flags
Cross-surface form (context) — see 12_API_Mapping.md §Org. Body = plain Record<string, boolean> (organizations.controller.ts:86) — full replace ($set: {'metadata.featureFlags': flags}, organizations.service.ts:149-151). Client must load the
current map (GET /organizations/:id/feature-flags, organizations.service.ts:139-142) and
merge locally before PATCH — any key omitted from the body is lost. Validate boolean
values; no DTO server-side (no class-validator) — send only booleans.
Form-level rules (all)
- Double-submit: disabled while pending.
- Optimistic: toggle-only (list/detail switches) is optimistic with rollback (00-shared/06 §3.5); editor save, bulk apply, and delete are server-confirm.
- Undo: delete has no undo (soft delete, but no restore endpoint; re-create blocked — OQ-4); toggles have no undo snackbar (rollback = flip again).
- Abandonment: dirty editor → discard-confirm dialog (Esc/back); injected key (deep link) preserved until saved.
- Keyboard:
.nextthrough fields, last.done;Entersubmits;Esccancels. - Error copy: from
messagefor business 4xx; codes for the rest; never render raw 5xx.
Client-side error priority
- 400 VALIDATION → fields / row mapping.
- 401 UNAUTHENTICATED → silent refresh; fail → sessionExpired.
- 404 RESOURCE_NOT_FOUND → "flag removed" (detail/delete).
- 403 PERMISSION_DENIED → hide affordance (target; not enforced today — OQ-5).
- 429 RATE_LIMITED → countdown (api tier 100/min,
rate-limit.constants.ts:6— rare for admin screens; treat generically). - 5xx → AppErrorState + requestId.
09 — User Behaviour (Feature Flags Module)
- 1. Expected behaviour (server-contract-driven)
- 2. Common mistakes & countermeasures
- 3. Power & new users
- 4. Mobile & a11y users
- 5. Interrupted sessions & retries
- 6. Abandonment
- 7. Frustration points → UX treatments (summary)
- 8. Cross-device & conflict
- 9. Adoption path
Expected behaviour derived from server semantics + shared interaction rules; common mistakes and UX countermeasures. Purpose: give the UI designer a playbook of what the user does so the flow never surprises.
1. Expected behaviour (server-contract-driven)
- A toggle is a truth claim: the switch shows server-confirmed state only after reconcile; optimistic apply rolls back on any error (06 §1).
- Everything the admin changes applies on next read. Today: immediate (no cache).
Once the Redis flag cache lands (
sl:global:featureflag:{name}, poll + cache 30 s —CACHE_ARCHITECTURE.md:33,48): up to 30 s skew. UI must not promise instant propagation; the client gating cubit re-reads on its own TTL and on pull-to-refresh. - Disabled is default for missing keys:
isEnabled()returnsflag?.enabled ?? false(feature-flags.service.ts:24) — the UI must treat "key absent from enabled set" as OFF everywhere, never as "unknown/loading". - Lists are complete, not paginated: all flag list endpoints return full arrays
(
feature-flags.service.ts:10-20) — no infinite scroll; a tenant catalog is small. - Delete removes from every surface but keeps the key slot: soft delete
(
base.repository.ts:68-74) + unique{tenantId,key}index (feature-flag.schema.ts:26) → re-create can 500 (OQ-4). Users who delete-and-recreate get a confusing failure — countermeasure: warn in delete dialog + error copy. - Org overlay replaces wholesale:
PATCH /organizations/:id/feature-flagsfull-replaces the map (organizations.service.ts:149-151) — a UI that edits one key and PATCHes the loaded map without merging silently deletes siblings. Merge-first is mandatory.
2. Common mistakes & countermeasures
| Mistake | Related | UX countermeasure |
|---|---|---|
| Admin flips a flag and expects it visible on a colleague's phone "right now" | propagation | info banner on toggle success (immediate today; "~30 s" once cache lands); pull-to-refresh affordance |
| Editing description/module and believing it saved | PUT /feature-flags drops them (feature-flag.repository.ts:40) | warning banner in editor (06 §3); never render those fields as saved |
| Bulk-updating then wondering which rows applied | sequential no-transaction bulk (feature-flags.service.ts:40-44) | per-row results + "retry failed" |
| Delete → re-create same key → 500 | unique index + soft delete (OQ-4) | delete dialog warning; error copy with cause hint |
| Org-level edit wiping sibling flags | full-replace $set (organizations.service.ts:149-151) | merge-before-PATCH; diff preview in the org settings UI (proposed) |
| Admin assumes permissions protect flags | none enforced (rbac.guard.ts:29) | UI gates affordances by permission anyway; server enforcement (planned) (OQ-5) |
| Typing keys with spaces/uppercase | no server pattern validation | client hint a-z 0-9 . _ - (proposed); trim on submit |
| Re-creating a key expecting a clean slate | upsert matches on {tenantId,key} — same doc re-enabled | editor copy: "Saving this key re-enables the existing flag" |
3. Power & new users
- Power (configurator): bulk selection via long-press on phone / Ctrl+Click on desktop;
keyboard
Nnew flag; expects per-row bulk results; copies keys with one tap. - New (first-time admin): empty-catalog onboarding: "No flags yet — create one, or contact your platform team" + link to what flags do (help text); first toggle → success snackbar teaches the effect (banner with propagation note).
4. Mobile & a11y users
- One-thumb: switch rows are the primary action — ≥ 48 dp targets, switch on the right edge; delete in menu, not swipe (destructive, 08 §1: no destructive swipes).
- TalkBack/VoiceOver: rows announce key + state; toggle via double-tap; bulk results announced (live region); editor errors focus-jump.
- Dynamic type 200%: key
monostrings may overflow — wrap with ellipsis + full value on detail; rows reflow. - Reduced motion: no row stagger; state changes = color+icon+text (
09 §8of shared).
5. Interrupted sessions & retries
- App killed mid-toggle: optimistic state lost; on restart list refetches → server truth.
- Server 5xx on save: form retained, snackbar with requestId; retry safe (upsert idempotent).
- 429 (api tier 100/min,
rate-limit.constants.ts:6): countdown; no auto-retry (00-shared/07 §4). - Offline: flags list from last-good cache + offline banner; all writes blocked (no offline queue for flags); switches disabled.
6. Abandonment
- Editor dirty → discard confirm; create abandoned → nothing created (no server touch).
- Bulk sheet dismissed mid-apply → apply continues server-side; on reopen the list refetch reflects final state; client tracks last-known partial result for "retry failed".
7. Frustration points → UX treatments (summary)
| Frustration | Treatment |
|---|---|
| "I toggled it but the app still shows it" | propagation banner + pull-to-refresh + client TTL explainer (Settings → data freshness line, (proposed)) |
| "It saved but my description is gone" | editor warning banner; field marked read-only |
| "Bulk said done but two rows didn't change" | per-row results with reasons; retry-failed button |
| "I deleted and now can't re-create" | delete dialog warning; error copy "key slot retained — ask platform team or use a new key" |
| "I can see the toggle in someone else's school" | impossible — tenant-scoped reads (base.repository.ts:20-30); QA check |
| Non-admin sees admin UI | permission-gated routes (target contract OQ-5) |
8. Cross-device & conflict
- Two admins toggle the same flag concurrently: last write wins (Mongo
findOneAndUpdate,feature-flag.repository.ts:38-42); version field (base.schema.ts:30-31) is not checked in this module's repo — no optimistic locking (OQ-8: acceptable for toggles?). - Admin A deletes while admin B edits → B's save upserts a new doc for the key (or E11000 — OQ-4); UI shows 404-first → banner + reopen.
- Client gating on device 1 vs 2 skews within client TTL — expected, documented in copy.
9. Adoption path
P0 (admin): list, single toggle, create/edit (key/enabled/label only — honest about the
description/module caveat), delete with warning. P1: bulk, module filtering, enabled tab,
affected-features map (proposed), org overlay merge UI. P2 (planned): server permission
enforcement, 30 s cache + invalidation events (OQ-7), gradual rollout fields (OQ-6),
institution-type matrices (IMPLEMENTATION_PLAN.md:310-336).
10 — Interaction Specification (Feature Flags Module)
- 1. Phone / touch
- 2. Keyboard / desktop / mouse
- 3. Haptics (mobile)
- 4. Motion language (module extension of 08 §4)
- 5. Micro-interactions catalogue (module)
- 6. Frustration controls (shared 08 §6 + module)
- 7. Special interactions
- 8. Validation of interaction to server semantics
Module-specific interactions layered on 00-shared/08. Covers every input modality on phone/tablet/desktop.
1. Phone / touch
| Gesture | Behaviour | Where |
|---|---|---|
| Tap | toggle switch, row → detail, chip filter, menu open | list, detail |
| Tap on switch while pending | ignored (switch disabled during flight) | all toggles |
| Long-press | enter selection mode (bulk), open AppMenu when menu exists | list rows |
| Swipe left/right | none (delete is destructive → menu + dialog only; 00-shared/08 §1 keeps ≤ 2 non-destructive actions) | — |
| Pull down | RefreshIndicator → full list refetch and FeatureFlagsCubit.refresh() (gating set stays in sync) | list |
| Hardware back | sheet → dismiss (selection kept); detail → list; editor dirty → discard confirm | all |
| Edge swipe | drawer (shell) | — |
2. Keyboard / desktop / mouse
| Input | Behaviour |
|---|---|
| Tab | chips → rows (row = one stop: switch+menu) → FAB; focus ring primary |
| Space | toggles focused switch; toggles chip selection |
| Enter | opens focused row detail; submits editor; applies bulk |
| Esc | closes sheet / dialog / discards editor (with confirm if dirty) |
| Ctrl/Cmd+N | new flag (editor, create mode) |
| Ctrl/Cmd+Click / Shift+Click | multi-select rows for bulk |
| Right-click | AppMenu (same as long-press) |
| Hover | row tint m-fast; tooltip after 500 ms (switch disabled reason, key copy) |
| Arrows | navigate chip rows in module filter; move between switch and menu in a row |
3. Haptics (mobile)
| Event | Haptic | Ref |
|---|---|---|
| Toggle applied (200) | lightImpact | — |
| Toggle rollback (error) | mediumImpact | — |
| Copy key | selectionClick | — |
| Bulk applied | lightImpact | — |
| Delete confirm | heavyImpact | — |
| Rule | off when system haptics off; none on desktop | 08 §3 |
4. Motion language (module extension of 08 §4)
| Transition | Duration/curve | Used by |
|---|---|---|
| List → detail | m-base slide (hero optional m-slow) | rows |
| Row insert/remove | m-entrance fade-up stagger 40 ms | after bulk/delete refresh |
| Switch state change | m-fast color/thumb | all toggles |
| Filter chip swap / tab | m-base cross-fade of list content | filter row |
| Sheet (bulk) | m-base bottom slide, r-xl top | bulk |
| Result rows | m-entrance fade-up | bulk results |
| Error rollback | AnimatedSwitcher fade + error ring (no shake on toggles) | switches |
Reduced motion: fades + m-instant only; no stagger, no hero.
5. Micro-interactions catalogue (module)
| Interaction | Detail |
|---|---|
| Switch flipped | thumb animates m-fast; row enters pending ("Saving…") without blocking other rows |
| Save in editor | button loading swap m-base; double-tap locked |
| Copy key | snackbar "Copied" + selectionClick |
| Bulk apply | progress bar m-base; count "Applying 3 of 5" |
| Delete | dialog m-base scale+fade; row fade-out on 200 |
| Filter change | list content cross-fade m-slow; active chip secondaryContainer |
| Gating flip (client) | gated widgets appear/disappear with m-fast fade — never a full-screen reload |
6. Frustration controls (shared 08 §6 + module)
- All presses respond < 100 ms with
m-fastripple. - Every network action shows pending state (switch pending, save spinner, bulk progress).
- Writes disabled while offline; tooltip explains.
- 429 → countdown; no auto-retry (
rate-limit.constants.ts:6). - Errors persist as banners/snackbars until dismissed or corrected; toggles roll back visibly (never silently).
7. Special interactions
- Bulk partial-failure retry: "Retry failed (n)" re-sends only missing keys
(
feature-flags.service.ts:40-44sequential apply → client diffs by key). - Deep-link prefilter:
studylyon://settings/feature-flags?module=biometricpre-selects the chip;:keydeep link loads detail directly (404 → "flag not found" + back). - Selection persistence: bulk sheet dismissal keeps selection; re-open restores it.
- No drag/drop, no QR, no media in this module.
8. Validation of interaction to server semantics
- Toggle rollback on error matches server truth (no cache of a state the server rejected).
- Editor never claims description/module saved (repo drops them,
feature-flag.repository.ts:40). - Bulk results respect sequential no-transaction semantics (
feature-flags.service.ts:40-44). - Delete shows the re-create caveat (unique index + soft delete, OQ-4).
- Org overlay UI merges before PATCH (full-replace semantics,
organizations.service.ts:149-151). - Enabled set refresh keeps client gating aligned with
GET /feature-flags/enabled(feature-flag.repository.ts:28-30).
11 — Design System Mapping (Feature Flags Module)
- 1. Theme recipes (module)
- 2. Widget → token
- 3. Typography mapping
- 4. Iconography
- 5. Motion tokens (values 00-shared/02 §6)
- 6. Contrast & dark parity
- 7. Breakpoint behaviour
- 8. Component mapping to shared library
Module → Material 3 / Flutter / token mapping, extending 00-shared/04. Every color, type, spacing, elevation, radius, icon, motion reference traces to 00-shared/02.
1. Theme recipes (module)
| Screens | Theme | Exception |
|---|---|---|
| Flags list / detail / editor | standard shell surface | enabled switches primary; selected rows primaryContainer; danger error |
| Bulk sheet | surfaceContainerHigh | apply CTA primary; per-row failure errorContainer |
| Editor warning banner | tertiaryContainer (advisory, not error) | description/module-not-saved caveat |
Affected-features card (proposed) | surfaceContainerLow | "hidden when off" badges outline |
2. Widget → token
| Component | M3 / Flutter | Tokens | Elev | Radius |
|---|---|---|---|---|
| Flag row | AppListTile | surface, switch primary | e-0 | — |
| Flag row (selected) | ListTile tinted | primaryContainer/onPrimaryContainer | e-0 | — |
| Key chip | Material chip | surfaceVariant fill, mono text | — | r-sm |
| Module badge | Badge/AppBadge | secondaryContainer | — | r-full |
| Enabled-only filter | FilterChip | selected secondaryContainer | — | r-sm |
| Detail card | AppCard | surfaceContainerLow | e-1 | r-lg |
| Editor fields | TextFormField | fill surfaceVariant, focus primary | — | r-md |
| Save CTA | FilledButton | primary/onPrimary | — | r-md |
| Delete | TextButton destructive | error | — | — |
| Warning banner | AppBanner | tertiaryContainer | e-1 | r-md |
| Bulk sheet | AppBottomSheet | surfaceContainerHigh | e-5 | r-xl |
| Bulk progress | LinearProgressIndicator | primary | — | — |
| Dialog (delete) | AppDialog | surfaceContainerHigh | e-4 | r-lg |
| FAB New flag | FloatingActionButton.extended | primaryContainer | e-3 | r-full |
| Snackbar | SnackBar | inverseSurface | e-5 | r-md |
| Switch (disabled-perm) | Switch | onSurfaceVariant 38% | — | — |
3. Typography mapping
| Token | Screen element |
|---|---|
headlineMedium | screen titles |
titleMedium | list rows, section headers (module groups) |
bodyMedium | label/description values |
bodySmall | created/updated timestamps, bulk result notes |
labelLarge | buttons, segmented button |
labelMedium | badge counts, "Saving…" |
mono | flag key, key chip, bulk result keys |
Timestamps use FontFeature.tabularFigures().
4. Iconography
Material Symbols outlined: flag, toggle_on/toggle_off (row leading), tune (filters),
add (FAB), edit, delete, content_copy, check_circle/cancel (bulk results),
apps/category (module badge), bolt (affected-features). Status icons (enabled/disabled)
use the filled variant.
5. Motion tokens (values 00-shared/02 §6)
| Purpose | Token | Applied to |
|---|---|---|
| Route transitions | m-base | list↔detail, editor push |
| Switch state | m-fast | all toggles |
| Filter/list cross-fade | m-slow | chip changes |
| Row insert/removal | m-entrance (40 ms stagger) | post-bulk/post-delete |
| Sheet | m-base | bulk sheet |
| Hero list→detail | m-slow | optional |
Reduced-motion: fades + instants only.
6. Contrast & dark parity
- Text 4.5:1; chrome 3:1 (09_Accessibility_Baseline.md §2).
- Enabled/disabled is never color-only: switch + icon + label on every row; bulk results
pair ✓/✕ with
success/error+ text reason (09 §9). - Dark: chromatic roles TBD by designer (00-shared/02 §1.4); key
monoonsurfaceVariantkeeps ≥ 4.5:1.
7. Breakpoint behaviour
| Class | List | Detail/Editor |
|---|---|---|
| phone < 600 | single pane, filter chips scroll horizontally | push navigation |
| 600–839 | chips + list | centered editor ≤ 560 px |
| ≥ 840 | master-detail (list 360 px left) | right pane detail; editor dialog-ish page |
| ≥ 1200 | master-detail + hover | keyboard shortcuts active |
8. Component mapping to shared library
All components from 00-shared/03 (see 05 §Shared components used); module-only additions
(AppFlagRow, AppFlagKeyChip, AppFlagGroupHeader, AppFlagEditorForm,
AppBulkUpdateSheet, AppAffectedMapCard, AppFlagToggleBanner) defined in
07_Component_Library.md with token references above.
12 — API Mapping (Feature Flags Module)
- 0. Module-wide request envelope & client policy
- Screen: Flags list (all / module / enabled)
- Screen: Flag detail
- Screen: Flag editor (create/edit) — single upsert
- Screen: Bulk update
- Screen: Delete flag
- Screen: Org-level flags (organization settings context)
- Loading / streaming / realtime
- Client-side error mapping table (module)
- Pagination
- Optimistic / undo
Exact wire contract for every screen → endpoint. Base
/api/v1; envelope per 00-shared/07. All endpoints fromsrc/modules/feature-flags/feature-flags.controller.tsand the org-overlay fromsrc/modules/organizations/organizations.controller.ts; business rules fromfeature-flags.service.ts. Global guards:RateLimitGuard→JwtAuthGuard→RbacGuard(app.module.ts:129-131). No flag endpoint is@Public(), and none declares@Permissions()— effective auth = valid JWT only (OQ-5; perms declared atpermissions.constants.ts:78-80).
0. Module-wide request envelope & client policy
| Aspect | Contract |
|---|---|
| Base | https://api.<domain>/api/v1 |
| Headers | Authorization: Bearer <accessToken>; x-request-id client-generated; Content-Type: application/json |
| Success | {success:true, message:"OK", data, meta?, timestamp, requestId} (response-envelope.interceptor.ts:50-59) |
| Error | {success:false, message, error:{code,details?}, timestamp, requestId} (http-exception.filter.ts:73-81) |
| Codes | 400 VALIDATION_ERROR · 401 UNAUTHENTICATED · 403 PERMISSION_DENIED · 404 RESOURCE_NOT_FOUND · 409 DUPLICATE_RESOURCE · 422 BUSINESS_RULE_VIOLATION · 429 RATE_LIMITED · 5xx INTERNAL_SERVER_ERROR (http-exception.filter.ts:27-35) |
| Tenancy | tenantId from JWT only; never in body. Reads auto-scoped (base.repository.ts:20-30); platform-admin reads bypass scope; upsert requires tenant context (feature-flag.repository.ts:37) |
| Rate limit | global api tier 100/min default (rate-limit.constants.ts:6, rate-limit.guard.ts:36) |
| Caching | none in code; blueprint: Redis sl:global:featureflag:{name} poll+cache 30 s (planned) (CACHE_ARCHITECTURE.md:33,48) |
| Offline | reads from last-good client cache; writes blocked |
| Retry | backoff on 5xx/network; no auto-retry on 429; bulk retry = failed subset only |
Screen: Flags list (all / module / enabled)
| Endpoint | GET /feature-flags (feature-flags.controller.ts:23-28) |
| Query | ?module=<string> optional → findByModule else findAll |
| Success | 200 data: [FeatureFlagDoc…] sorted by key asc (feature-flags.service.ts:11) — plain array, no meta (not paginated) |
| Errors | 401; 5xx |
| Enabled tab | GET /feature-flags/enabled (feature-flags.controller.ts:30-34) → data: [docs with enabled:true] (feature-flag.repository.ts:28-30) — this is the gating set the client cubit mirrors |
| Client flow | fetch on screen open; RefreshIndicator refetch; cache last-good per sl:cache:featureflags:{query} |
Doc shape (FeatureFlag + BaseSchema)
{
"_id": "…", "tenantId": "…",
"key": "channels.whatsapp", "enabled": false,
"label": "WhatsApp", "description": "…", "module": "channels",
"createdBy": "…", "updatedBy": "…",
"isDeleted": false, "deletedAt": null, "deletedBy": null,
"version": 0, "createdAt": "…", "updatedAt": "…"
}
(feature-flag.schema.ts:7-22, base.schema.ts:10-34; timestamps:true)
Screen: Flag detail
| Endpoint | GET /feature-flags/:key (feature-flags.controller.ts:36-40) |
| Success | 200 data: doc |
| Errors | 404 RESOURCE_NOT_FOUND "Feature flag "feature-flags.service.ts:29); 401 |
| Note | soft-deleted flags are invisible (scoped isDeleted:false) → 404 |
Screen: Flag editor (create/edit) — single upsert
| Endpoint | PUT /feature-flags (feature-flags.controller.ts:42-46) — upsert: create or update |
| Body | UpdateFeatureFlagDto: {key: string (req), enabled: boolean (req), label?: string, description?: string, module?: string} (update-feature-flag.dto.ts:4-26) |
| Success | 200 data: doc — {new:true} upsert result (feature-flag.repository.ts:41) |
| Persistence truth | $set: {enabled, label} only (feature-flag.repository.ts:40) — description/module dropped on every write (OQ-3) |
| Errors | 400 VALIDATION_ERROR (missing key/enabled, wrong types — details[]); 5xx incl. E11000 duplicate on re-create after soft-delete (OQ-4) |
| Client flow | create mode: new key → new doc; edit mode: key read-only, label/enabled editable; never claim description/module saved |
Screen: Bulk update
| Endpoint | PUT /feature-flags/bulk (feature-flags.controller.ts:48-52) |
| Body | JSON array of UpdateFeatureFlagDto (feature-flags.controller.ts:50) |
| Success | 200 data: [docs] — applied sequentially, no transaction, no rollback (feature-flags.service.ts:40-44) |
| Errors | 400 whole-body validation (array-item errors in details[]); 5xx mid-loop → partial apply |
| Client flow | per-row results by key diff; retry failed subset only |
Screen: Delete flag
| Endpoint | DELETE /feature-flags/:key (feature-flags.controller.ts:54-58) |
| Success | 200 (envelope, data absent/void) — soft delete by _id (feature-flags.service.ts:47-52, base.repository.ts:68-74) |
| Errors | 404 first-guard (findByKey) / second-guard (softDelete returns falsy → 404) (feature-flags.service.ts:48-51); 401 |
| Note | key slot retained by unique {tenantId,key} index (feature-flag.schema.ts:26) → re-create may 500 (OQ-4) |
Screen: Org-level flags (organization settings context)
| Endpoint | GET /organizations/:id/feature-flags (organizations.controller.ts:74-79) |
| Success | 200 data: {flagKey: boolean…} from org.metadata.featureFlags (organizations.service.ts:139-142); empty object when unset |
| Endpoint | PATCH /organizations/:id/feature-flags (organizations.controller.ts:81-89) |
| Body | plain Record<string, boolean> (organizations.controller.ts:86) — no DTO, no validation |
| Semantics | full replace: $set: {'metadata.featureFlags': flags} (organizations.service.ts:149-151) — omitted keys are deleted |
| Success | 200 data: {flagKey: boolean…} (updated map) |
| Errors | 404 org missing; 401; (id is org doc id — platform/org-admin context) |
| Client flow | load map → merge locally → PATCH full merged map; diff preview (proposed) |
| Note | metadata is a free-form object (organization.schema.ts:129-130); this surface is not connected to the feature_flags collection or isEnabled() (OQ-2) |
Loading / streaming / realtime
| Screen | Loading | Streaming | Realtime |
|---|---|---|---|
| list | AppSkeleton(list) | — | (planned) WS topic featureflag.changed (not in 00-shared/07 §8 topic list; no domain event in code — OQ-7); client falls back to TTL refresh |
| detail | skeleton | — | — |
| editor | save spinner | — | — |
| bulk | progress bar | — | — |
Client-side error mapping table (module)
| Screen | code | UI |
|---|---|---|
| any | 401 | silent refresh; fail → sessionExpired |
| detail | 404 | AppEmptyState "Flag not found — deleted?" |
| editor save | 400 | field errors from details[] |
| editor save | 5xx | snackbar + requestId; form retained |
| toggle | 5xx | rollback switch + snackbar |
| bulk | 400 | offending rows highlighted |
| bulk | 5xx mid | partial results + "retry failed" |
| delete | 404 | treat as already-removed |
| org overlay | 404 | org missing → settings error state |
Pagination
None — every flag endpoint returns full arrays (no meta). Client renders all rows
(feature-flags.service.ts:10-20); bulk selection is client-side.
Optimistic / undo
- Toggles (list + detail): optimistic with rollback (
00-shared/06 §3.5); undo = flip again. - Editor save, bulk apply, delete, org overlay PATCH: server-confirm, never optimistic.
- Delete has no undo (soft delete; no restore endpoint; re-create caveat OQ-4).
13 — State Management (Feature Flags Module)
- 1. FeatureFlagsCubit (global gating — owns the enabled set)
- 2. Per-screen Cubits
- 3. State objects (concise)
- 4. Events & actions map (UI → Cubit → API)
- 5. Caching & refresh
- 6. Realtime
- 7. Error states per action
- 8. Testing hooks (
00-shared/06 §6) - 9. Cross-cutting interplay
Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) plus the module-wide
FeatureFlagsCubit— the global gating state every other module consumes (00-shared/06 §4: "FeatureFlagsCubitgates UI per tenant (biometric, SMS, WhatsApp channels)"; also referenced at auth/13 §9: "FeatureFlagsCubitnot used (auth module runs pre-feature-gate)"). Backed byFeatureFlagsRepository(dio) hitting the endpoints in 12_API_Mapping.md.
1. FeatureFlagsCubit (global gating — owns the enabled set)
stateDiagram-v2
[*] --> initial
initial --> loading : boot / app resume / TTL tick
loading --> loaded : GET /feature-flags/enabled 200
loading --> staleError : 401 (refresh fail → sessionExpired) / 5xx
staleError --> loading : retry / next tick (keep last-good set)
loaded --> refreshing : pull-to-refresh / TTL expiry
refreshing --> loaded : 200 (set replaced)
loaded --> refreshing : admin toggled a flag (admin flow)
loaded --> loaded : set unchanged
- State:
FeatureFlagsState { status, enabledKeys: Set<String>, moduleIndex: Map<String, Set<String>>, flags: Map<String, FlagMeta>, lastUpdated }. - Data source:
GET /feature-flags/enabled(feature-flags.controller.ts:30-34,feature-flag.repository.ts:28-30) → keys ofenabled:truedocs. The admin surfaces also fetch full docs (GET /feature-flags,:key) — the cubit keeps a lightFlagMetamirror (key, enabled, module, label) so gated widgets can show labels without an extra fetch. - Synchronous gate API:
bool isEnabled(String key)→enabledKeys.contains(key)— fail-closed: missing key = disabled, mirroring serverflag?.enabled ?? false(feature-flags.service.ts:24). No async at widget build time. - TTL (proposed, mirrors blueprint): refresh every 30 s (
(proposed)client value aligned withCACHE_ARCHITECTURE.md:4830 s poll; server cache is(planned)) while app foregrounded; also refresh onapp.resume, on connectivity restore, and onRefreshIndicator(bypasses TTL). When offline: keep last-good set +AppOfflineBanner(reads for gating continue from memory; writes blocked). - Realtime: no WS topic for flags exists (
00-shared/07 §8);(planned)topicfeatureflag.changed(user room) once a domain event exists (OQ-7) — until then TTL polling is the propagation mechanism. On any admin toggle (list/detail), the cubit optimistically updates its own set on 200 and schedules an immediate refresh. - Registry (proposed): static map
key → List<ScreenRef>in the app (used byAppAffectedMapCard, 07/05 §5). Used only for the affected-features map; gating itself needs no registry — any widget callsisEnabled(key).
2. Per-screen Cubits
| Screen | Cubit | Events → State |
|---|---|---|
| List | FlagsListCubit | Load, LoadAll, LoadModule(m), LoadEnabledOnly(bool), Refresh, Toggle(key, enabled), EnterSelection, ToggleSelect(key), SelectAll, ClearSelection, Delete(key) → {listState: LoadState, filters{module, enabledOnly}, groups, rows{key: {doc, pending, error}}, selection{Set<key>, mode}} |
| Detail | FlagDetailCubit | Load(key), Toggle(enabled), Delete(), Save(dto), Refresh → {LoadState, flag?, pending, deleted} |
| Editor | FlagEditorCubit | Init(flag?, createMode), Submit(dto) → {form, saving, fieldErrors, saved(doc), notSaved: {description, module}} |
| Bulk | BulkUpdateCubit | Apply(keys, state, label?), RetryFailed(), Dismiss() → `{applying, progress, results: Map<key, ok |
Org overlay (context) | OrgFlagsCubit | Load(orgId), Patch(mergedMap) → {LoadState, map, saving} — merge-first discipline (organizations.service.ts:149-151) |
- Loading/caching: list caches last-good per filter key (
sl:cache:featureflags:{module}(proposed));RefreshIndicatorbypasses cache (00-shared/06 §3.3). Detail: no cache (server truth; 404 handling).
3. State objects (concise)
class FeatureFlagsState {
FeatureFlagStatus status; // initial | loading | loaded | staleError
Set<String> enabledKeys; // gate set (fail-closed)
Map<String, FlagMeta> flags; // key → meta (module, label, enabled)
DateTime? lastUpdated;
}
class FlagMeta { String key; bool enabled; String? label; String? module; }
class FlagDoc extends FlagMeta {
String? description; String? module; int version;
DateTime createdAt, updatedAt;
// maps FeatureFlagDocument 1:1 (feature-flag.schema.ts:9-22 + base.schema.ts:10-34)
}
class FlagGroup { String? module; List<FlagDoc> flags; } // null module → "Ungrouped"
class BulkResult { String key; bool ok; String? reason; }
4. Events & actions map (UI → Cubit → API)
| UI event | Cubit method | Repository call |
|---|---|---|
| app boot / resume / TTL | flagsCubit.refresh() | GET /feature-flags/enabled |
| screen open (admin) | list.load() | GET /feature-flags or ?module= / /enabled |
| row switch flip | list.toggle(key, v) | PUT /feature-flags {key, enabled:v, label} |
| detail toggle | detail.toggle(v) | PUT /feature-flags … |
| editor save | editor.submit(dto) | PUT /feature-flags |
| bulk apply | bulk.apply(keys, state, label) | PUT /feature-flags/bulk (array) |
| bulk retry failed | bulk.retryFailed() | PUT /feature-flags/bulk (failed subset) |
| delete confirm | list.delete(key) / detail.delete() | DELETE /feature-flags/:key |
| org overlay save | orgFlags.patch(merged) | PATCH /organizations/:id/feature-flags |
All through FeatureFlagsRepository; widgets never call dio (00-shared/06 §2).
5. Caching & refresh
FeatureFlagsCubit: in-memory enabled set (single source for gating); no persistence — on cold boot it refetches before first gate decisions; until loaded, UI shows skeletons in gated areas (never a wrong "disabled" flash — gate states areloading|loaded).- List: last-good per filter; detail: none; editor/bulk: none.
- TTL 30 s
(proposed);RefreshIndicatorand admin toggle success force immediate refresh.
6. Realtime
- No realtime today.
(planned): WS topicfeatureflag.changedto user rooms →FeatureFlagsCubitapplies the new state immediately (subscribes via repositorysubscribe(channel)per00-shared/06 §3.4). Until then, worst-case propagation = client TTL (30 s) + server cache TTL (30 s(planned)CACHE_ARCHITECTURE.md:48).
7. Error states per action
| Action | Error | State → |
|---|---|---|
| gating refresh | 401 | refresh → sessionExpired (gates keep last-good during refresh) |
| gating refresh | 5xx | staleError; keep last-good; retry next tick — never gate on error |
| toggle | 5xx/400 | rollback + row error |
| save | 400 | fieldErrors |
| save | 404 | "flag removed" banner → reopen list |
| bulk | 400/5xx | partial results + retry |
| delete | 404 | treat-as-removed |
| org patch | 404 | org missing → settings error |
8. Testing hooks (00-shared/06 §6)
- Pure-Dart cubits:
FeatureFlagsCubitstate machine (initial→loading→loaded→staleError), fail-closedisEnabled, TTL timer, merge-on-admin-toggle. - Widget tests: list filter/toggle/rollback; bulk partial results; gated widget appears/ disappears on set change (fake async TTL).
9. Cross-cutting interplay
ConnectivityCubit: offline → gating uses in-memory set, admin writes blocked; on reconnect → immediate refresh.AuthCubit: on login/tenant switch →FeatureFlagsCubitreset + load for the new tenant; onsessionExpired→ clear set.- Other modules: biometric/notifications/payments screens call
flagsCubit.isEnabled(key)to hide/show features — no consumer exists in the backend today (OQ-1); the cubit is the client-side counterpart of the server'sisEnabled()primitive (feature-flags.service.ts:22-25) and of the roadmap'suseFeatureFlags()hook (IMPLEMENTATION_PLAN.md:810-817,(planned)). - Permission changes (
00-shared/06 §3.6) rebuild admin routes; flag state is orthogonal.
14 — QA Checklist (Feature Flags Module)
- 1. Functional
- 2. Visual / layout
- 3. Accessibility (00-shared/09)
- 4. Performance
- 5. Offline & network (10 §2)
- 6. Tablet / desktop
- 7. Localization
- 8. Permissions
- 9. Dark mode
- 10. Animations
- 11. Security
- 12. Server-backed edge mirrors
- QA scripts
Module QA specific to the framework + shared 00-shared/10. Every check derives from a specific backend behavior so failure analysis is direct.
1. Functional
-
GET /feature-flagsreturns all tenant flags sorted bykeyasc (feature-flags.service.ts:11), plain array, nometa. -
GET /feature-flags?module=channelsreturns only matchingmodule(feature-flag.repository.ts:24-26). -
GET /feature-flags/enabledreturns onlyenabled:truedocs (feature-flag.repository.ts:28-30). -
GET /feature-flags/:keyreturns the doc; unknown/soft-deleted key → 404RESOURCE_NOT_FOUND("Feature flag "" not found." — feature-flags.service.ts:29). -
PUT /feature-flagswith new key creates (upsert:true,feature-flag.repository.ts:38-42); same key updates (merge). -
Upsert persists exactly
enabled+label— description/module silently dropped (feature-flag.repository.ts:40): verify the editor warning banner exists and no "saved" claim is made for those fields. -
Upsert with missing
keyor non-booleanenabled→ 400VALIDATION_ERRORwithdetails[](update-feature-flag.dto.ts:5-11). -
PUT /feature-flags/bulk(array body) applies sequentially (feature-flags.service.ts:40-44); client shows per-row results; a 5xx mid-list leaves earlier items applied (no rollback) → "retry failed" works. -
Bulk body with one invalid item → whole-body 400 with array-item errors in
details[]. -
DELETE /feature-flags/:keysoft-deletes (flag disappears from all three list endpoints); second delete of same key → 404 (feature-flags.service.ts:47-52). - Delete → re-create same key → expect E11000 → 500 today (OQ-4); QA must verify the current error copy is shown, not a raw crash.
-
Org overlay:
PATCH /organizations/:id/feature-flagsreplaces the whole map (organizations.service.ts:149-151) — QA: PATCH{a:true}then{b:true}→ais gone. Merge-first client behavior verified. -
Flag written via
PUT /feature-flagsdoes not appear inorg.metadata.featureFlagsand vice versa (two disconnected surfaces — OQ-2). -
Cross-tenant: org A's flag list never contains org B's keys (scoped reads,
base.repository.ts:20-30). -
JWT-only gate: flag endpoints work with a valid JWT of any role (no
@Permissions— OQ-5) — QA documents current behavior; UI affordances still followfeature-flags.*(target contract). -
Rate limit: > 100 req/min on flags → 429 (
rate-limit.constants.ts:6).
2. Visual / layout
-
List groups by
module; null-module flags land in an "Ungrouped" bucket (schema allows absentmodule,feature-flag.schema.ts:21-22). - Dark/light parity: switches, selected rows, bulk results (no hardcoded colors).
-
Long keys (
a.very.long.module.namespace.key) wrap/ellipsize at 200% text scale without clipping. - Bulk results row = icon + reason text (never color-only, 09 §9 shared).
3. Accessibility (00-shared/09)
- TalkBack: browse → toggle → detail → editor save → bulk apply, fully operable without sight.
- VoiceOver separately.
-
Switch rows announce key + state (
Semantics(toggled:)); live region announces "Saving…" and bulk progress/results. - Editor: labels linked, first-invalid focus jump, error announced.
- Gated (hidden) features are removed from semantics — screen readers never read a disabled feature.
- Keyboard-only desktop: chips → rows → FAB order, visible focus ring.
-
Contrast: body 4.5:1, chrome 3:1;
monokeys ≥ 4.5:1 onsurfaceVariant. - Reduced motion: no stagger/hero/shake.
4. Performance
- List load (cold) skeleton ≤ 200 ms, content ≤ 2 s (00-shared/10 §1).
-
Toggle round-trip p95 < 1 s (single
findOneAndUpdate,feature-flag.repository.ts:38-42). -
Bulk ≤ 20 flags < 3 s (sequential loop,
feature-flags.service.ts:40-44). -
FeatureFlagsCubitTTL refresh does not rebuild gated screens wholesale (selector-based rebuilds). - No duplicate parallel flag requests on screen open + TTL tick.
5. Offline & network (10 §2)
- Offline → list renders last-good + banner; switches disabled (no silent writes).
- Gating offline → last-good set; no "disabled" flash from error states.
- 5xx on toggle → rollback + snackbar with requestId.
- 429 → countdown, no auto-retry.
-
Reconnect → immediate
FeatureFlagsCubit.refresh().
6. Tablet / desktop
- Master-detail list↔detail at ≥ 840 px.
- Hover row tint, tooltips (switch disabled reason), right-click menu.
- Keyboard: Space toggles switch; Ctrl/Cmd+N new; Esc closes sheet/dialog.
7. Localization
- All strings via i18n keys; module names/labels are tenant data (rendered as-is, not translated).
- Date/time locale for createdAt/updatedAt.
- Long localized strings in editor warning banner reflow at 200% scale.
8. Permissions
- Server: flag endpoints accept any JWT today (OQ-5) — QA confirms and files the gap.
-
Client: routes/affordances hide without
feature-flags.read; toggle/edit/bulk/delete withoutfeature-flags.update/.delete(target contract — UI works either way). -
Org overlay uses
organization.update-adjacent gating (org settings context).
9. Dark mode
-
Full visual pass; selected rows, bulk ✓/✕, warning banner (
tertiaryContainer) contrast.
10. Animations
- Tokens only; reduced-motion respected; toggle rollback never animates a false state.
11. Security
-
Keys/values never logged; no tenant data in analytics events (only key names where agreed
(proposed)). - Flag values not cached in plain-text prefs — in-memory only (13 §5).
-
No tenantId sent in bodies (server derives from JWT; upsert uses
requireTenantId(),feature-flag.repository.ts:37).
12. Server-backed edge mirrors
- 404 detail for deleted flag → "Flag not found — it may have been deleted".
- E11000 re-create → friendly error (OQ-4).
- Full-replace org overlay preserved client-side (merge-before-PATCH).
-
isEnabled('anything')with empty catalog → false everywhere (fail-closed,feature-flags.service.ts:24) — gated screens all hidden on fresh tenant.
QA scripts
- Golden per component:
AppFlagRow(on/off/pending/selected/disabled-perm), editor (create/edit/error-banner), bulk sheet (idle/applying/results), light/dark × 3 sizes. - E2E (device-cloud P0): login admin → browse flags → toggle → bulk 3 → delete with warning → re-create attempt (expect graceful error today) → verify gated screen hides.
- Gating journey: teacher logs in on tenant with
channels.smsoff → SMS entry absent; admin enables → within TTL it appears (or on pull-to-refresh immediately).
15 — Flutter Implementation Guide (Feature Flags Module)
- 1. Folder structure
- 2. Dependencies
- 3. Cubits
- 4. FeatureFlagsRepository (single)
- 5. Navigation
- 6. Gating (the module's cross-cutting deliverable)
- 7. Theme
- 8. Extensions
- 9. Localization keys
- 10. Testing
- 11. Performance
- 12. Proposals flagged to the team
How to build the Feature Flags feature in the Flutter client on top of 00-shared/11. Forward-looking spec; no client repo exists yet. Backend surface is complete (
src/modules/feature-flags/**).
1. Folder structure
lib/features/feature_flags/
├── domain/
│ ├── models/
│ │ ├── flag_doc.dart # key, enabled, label?, description?, module?, version, createdAt, updatedAt
│ │ └── flag_group.dart # module? + flags (null module = "Ungrouped")
│ └── gating/
│ └── flag_gate.dart # FlagGate widget + registry (see §6)
├── data/
│ ├── dto/
│ │ └── update_flag_dto.dart # key, enabled, label?, description?, module?
│ └── repositories/
│ └── feature_flags_repository.dart
└── presentation/
├── cubit/
│ ├── feature_flags_cubit.dart # GLOBAL gating cubit (app-level, not feature-scoped)
│ ├── flags_list_cubit.dart
│ ├── flag_detail_cubit.dart
│ ├── flag_editor_cubit.dart
│ └── bulk_update_cubit.dart
├── pages/
│ ├── flags_list_page.dart
│ ├── flag_detail_page.dart
│ └── flag_editor_page.dart
└── widgets/
├── flag_row.dart
├── flag_key_chip.dart
├── flag_group_header.dart
├── flag_editor_form.dart
├── bulk_update_sheet.dart
└── affected_map_card.dart
FeatureFlagsCubit is registered in the app-level get_it (single instance) so any
module can gate; screen cubits live in this feature folder.
2. Dependencies
flutter_bloc, dio (AppDio), go_router, get_it, intl. No new packages — switches,
chips, sheets are Material 3 core.
3. Cubits
FeatureFlagsCubit(§13): state{status, enabledKeys, flags, lastUpdated}; API:refresh(),isEnabled(String key),forceRefresh(). Timer-driven TTL 30 s(proposed); subscribe toConnectivityCubitfor refresh-on-reconnect; reset on tenant switch (listen toAuthCubit).FlagsListCubit: filters (module, enabledOnly), groups, per-row pending/error, selection mode for bulk;Toggle(key, value)→ optimistic + rollback;Delete(key).FlagDetailCubit: Load/Toggle/Delete/Save.FlagEditorCubit: create vs edit; on submit, keepnotSavedflags for description/module (server drops them —feature-flag.repository.ts:40).BulkUpdateCubit: Apply → sequential results; RetryFailed subset.
4. FeatureFlagsRepository (single)
class FeatureFlagsRepository {
// throws ApiException(code,status)
Future<List<FlagDoc>> list({String? module}); // GET /feature-flags[?module=]
Future<List<FlagDoc>> listEnabled(); // GET /feature-flags/enabled
Future<FlagDoc> byKey(String key); // GET /feature-flags/:key
Future<FlagDoc> upsert(UpdateFlagDto dto); // PUT /feature-flags
Future<List<FlagDoc>> bulkUpsert(List<UpdateFlagDto> dtos); // PUT /feature-flags/bulk
Future<void> remove(String key); // DELETE /feature-flags/:key
}
// org overlay (settings module or here):
Future<Map<String, bool>> orgFlags(String orgId); // GET /organizations/:id/feature-flags
Future<Map<String, bool>> patchOrgFlags(String orgId, Map<String, bool> merged); // PATCH …
DTO → model: FlagDoc.fromJson maps FeatureFlagDocument fields exactly
(feature-flag.schema.ts:9-22 + base.schema.ts timestamps); isDeleted docs never arrive
(scoped queries).
5. Navigation
go_router routes (proposed in 04): /settings/feature-flags, :key, :key/edit; guards:
authGuard + permissionGuard('feature-flags.read') (target contract — server doesn't
enforce yet, rbac.guard.ts:29, OQ-5). Bulk = sheet, not route. Deep links:
studylyon://settings/feature-flags?module= and :key.
6. Gating (the module's cross-cutting deliverable)
// shared/gating/flag_gate.dart — used by ANY module
class FlagGate extends StatelessWidget {
const FlagGate({required this.key, required this.builder, this.fallback});
final String key; final Widget Function() builder; final Widget? fallback;
Widget build(context) {
final flags = context.watch<FeatureFlagsCubit>();
switch (flags.status) {
case initial: case loading: return fallback ?? const AppSkeleton.rect();
default: return flags.isEnabled(key) ? builder() : (fallback ?? const SizedBox.shrink());
}
}
}
- Fail-closed:
isEnabledreturns false for missing keys (feature-flags.service.ts:24mirrored); duringloadingrender a skeleton or nothing — never a flash of the feature, never a flash of a "disabled" empty state. - Fallback pattern for admin docs:
FlagGate(key:'channels.sms', fallback: AppBanner(...)). - Registry (proposed, for
AppAffectedMapCard):const kFlagScreens = { 'channels.sms': [ScreenRef('SMS compose', '/messages/sms')], … }— static list, reviewed by product.
7. Theme
No new tokens; component mappings in 11_Design_System_Mapping.md all reference existing
AppTokens (00-shared/02). Switches follow M3 defaults with primary active.
8. Extensions
String.dotToSpaces()/ key prettifier for list subtitles(proposed).DateTime.toRelative()for "Updated 2 h ago".bool → Semantics.toggledhelper for switch rows.
9. Localization keys
featureflags.list.*, featureflags.detail.*, featureflags.edit.*,
featureflags.bulk.*, featureflags.gate.hidden ("Ask your admin to enable this feature"),
featureflags.delete.warning (re-create caveat). Server messages mapped to keys for known
404/400 business text; fallback rules per 00-shared/11 §9.
10. Testing
- Unit:
FeatureFlagsCubitstate machine + fail-closedisEnabled+ TTL timer (fake-async);FlagsListCubittoggle rollback;BulkUpdateCubitpartial results; DTO→model mapper (including absentmodule→ "Ungrouped"). - Widget: list filter/toggle/selection; editor warning banner; bulk results;
FlagGatethree states (loading → shown / hidden). - Golden: components × light/dark × 3 sizes (
00-shared/10 §9). - Integration: admin toggle → teacher gated screen updates within TTL (mock server); offline gating stays fail-closed.
- E2E (P0): admin browse → toggle → bulk → delete; teacher gating journey.
11. Performance
ListView.builderfor groups (flatten groups → index);constconstructors; selector (BlocSelector) onenabledKeysso gating rebuilds are scoped to watching widgets.- TTL timer pauses in background (app lifecycle) and fires on resume; no timers leaking in tests.
- List small (tenant catalogs are tens of rows) — no virtualization beyond builder.
12. Proposals flagged to the team
- When the server enforces
feature-flags.*(OQ-5), wirepermissionGuards — UI is already permission-shaped. - When the Redis flag cache + invalidation land (
CACHE_ARCHITECTURE.md:33,48, OQ-7), enable theAppFlagToggleBanner"~30 s" copy and drop the client TTL to match. - When
description/modulepersist (OQ-3) and rollout fields exist (OQ-6), extend the editor form (one-line enablement per 08 §1) + percentage/audience controls. - When a WS
featureflag.changedtopic exists (OQ-7), subscribe inFeatureFlagsCubitand bypass the TTL. - Analytics wiring waits the shared
AnalyticsServiceinterface (00-shared/10 §8).
01 — Product Overview (Fees Module)
- 1. Purpose
- 2. Business goals
- 3. User goals
- 4. Stakeholders
- 5. Why this exists
- 6. Dependencies
- 7. Success metrics
- 8. Edge cases (contract level)
- 9. Assumptions (module)
- 10. Open questions (module-grain; global ledger in 00-shared/12)
- 11. Glossary (this module)
StudyLyon — multi-tenant ERP / School Management API. This package designs the Fees module client (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, domain events, queue routes, permissions, and wire contracts are derived directly from
src/modules/fees/**,src/modules/payments/**,src/modules/scheduler/**,src/infrastructure/bullmq/**,src/infrastructure/workers/finance.worker.ts,src/modules/rbac/permissions.constants.ts,studylyon-blueprint/04-Modules/Fees.md, anddocs/IMPLEMENTATION_PLAN.md. No feature is invented — anything not present in source is flagged(planned)/(proposed)/(forward-looking)in Assumptions & Open Questions.
Heads-up: per the PRD, the mobile client is out of Phase 1; this package is the forward-looking spec the client will be built against later. Everything below is best-effort UI design on top of the current backend surface.
1. Purpose
Fees is the money lifecycle of a school: an admin defines a fee structure per
class + academic term (line items such as tuition/lab/transport, a total, currency,
due date, optional late fee); invoices are generated per student; payments are
recorded against invoices (offline cash/mobile-money via the fees module,
online gateway capture via the payments module); the system tracks dues
(outstanding balances) and — via the scheduler — flags overdue invoices and
enqueues payment reminders. Money rules come from
studylyon-blueprint/04-Modules/Fees.md; the code is the source of truth.
| Responsibility | Source |
|---|---|
| Fee structure CRUD (create/list/get/patch/soft-delete) | fees.controller.ts:28-56, fees.service.ts:42-98 |
| Generate one invoice per (student, feeStructure, academicYear) | fees.service.ts:101-140, unique index invoice.schema.ts:58-60 |
| List a student's invoices | fees.service.ts:142-144, invoice.repository.ts:17-21 |
Record a payment idempotently (idempotencyKey unique) | fees.service.ts:147-188, payment.schema.ts:40-41 |
Track dues (ISSUED/PARTIAL/OVERDUE, due = total − paid) | fees.service.ts:190-221 |
| Online payment capture + refund + reconcile + receipts | payments.controller.ts:24-73, payments.service.ts:31-217 |
Overdue detection (OVERDUE status) | finance.worker.ts:75-91 |
Reminder scan (due within 3 days) → payment-reminder queue | fee-reminder.job.ts:16-46 |
| Repeatable scheduler jobs (daily cron) | scheduler.service.ts:48-119 |
| Tenant scoping + soft-delete on every query | base.repository.ts:20-30 |
2. Business goals
| Goal | Measure |
|---|---|
| No invoice duplicates | unique index {tenantId, studentId, feeStructureId, academicYearId} + service 409 (invoice.schema.ts:58-60, fees.service.ts:107-113) |
| No double-counted payments | unique idempotencyKey; replay returns existing payment (payment.schema.ts:40-41, fees.service.ts:148-152) |
| Dues always computable | due = totalAmount − paidAmount per invoice (fees.service.ts:213-216) |
| Cross-tenant isolation | every query tenant-scoped via BaseRepository.scopedFilter (base.repository.ts:20-30) |
| Audit + notification trail | FeeStructureCreated / InvoiceIssued / PaymentCompleted events → BullMQ (event-queue-map.ts:40-42) |
3. User goals
- Admin / bursar (accounts): define a term's fee structure once; generate invoices per student; see outstanding dues per invoice.
- Accountant (cashier): collect cash/mobile-money against an invoice and post a payment; never double-post (idempotency key).
- Parent / payer: see child's invoices and dues; pay online through the payments module; read receipts.
- Org admin: financial oversight (dues report, payment history).
4. Stakeholders
Org admin, bursar/accounts staff, cashier (Accountant role), parents, students, the notification/email pipeline, the scheduler (overdue + reminders), audit/compliance (event log), QA + design + engineering.
5. Why this exists
Fee collection is a money path: the backend already enforces the important
invariants (one invoice per term, idempotent payments, soft-deleted structures,
tenant isolation). The Client's job is to present balances and payment statuses
authoritatively, never guess server state, and treat every write (payment, invoice
generation, waiver (planned)) as server-confirmed.
6. Dependencies
| Dependency | Role | Source |
|---|---|---|
| Payments module | Online capture (gateway), refund, reconcile, receipts | payments.controller.ts:21-73, payments_v2 schema |
| Notifications/emails | InvoiceIssued → send-invoice; PaymentCompleted → send-receipt | event-queue-map.ts:41-42 |
| Scheduler | overdue scan + fee-reminder crons | scheduler.service.ts:48-97 |
BullMQ invoice-generate | FinanceWorker (overdue marking; event handling) | finance.worker.ts:16-91 |
BullMQ payment-reminder | reminder fan-out jobs | fee-reminder.job.ts:26-39 |
| Collections | fee_structures, invoices, payments, payments_v2, receipts | *-schema.ts |
7. Success metrics
- Invoice generation (single student) round-trip < 2 s (sync path today).
- Duplicate-invoice attempt handled as 409 100% of the time (never a crash).
- Payment replay with same
idempotencyKeyreturns the original payment (no dup). - Zero cross-tenant leaks in any fees list/detail (
BaseRepositoryscope). - Dues balance renders exact:
totalAmount − paidAmountfrom server, not summed on the client.
8. Edge cases (contract level)
- Duplicate invoice (same student+structure+year) → 409
DUPLICATE_RESOURCE"Invoice already exists for this student and term." (fees.service.ts:107-113)- unique index.
- Pay on PAID/CANCELLED invoice → 409
DUPLICATE_RESOURCE"Invoice is already paid or cancelled." (fees.service.ts:155-160). - Unknown structure / invoice → 404
RESOURCE_NOT_FOUND(fees.service.ts:71-77,101-106). - Invalid Mongo id →
CastError→ 400VALIDATION_ERROR"Invalid resource identifier." (http-exception.filter.ts:47-48,91-95). - Delete structure is a soft delete (
softDelete,base.repository.ts:68-74); invoices are not cascaded. - Discounts reduce the invoice total (
totalAmount = max(0, structureTotal − Σdisc),fees.service.ts:117-119) — invoices total never below 0; the discount is snapshotted on the invoice asdiscounts[]. - Overpayment has no cap server-side —
@Min(0)only (record-payment.dto.ts:19);paidTotal >= totalAmountis what flips status to PAID (fees.service.ts:169-172). - Human currency is not integer-minor-enforced in code — blueprint says minor units
(Fees.md:63) but DTOs/schemas store plain
Number(fee-structure.schema.ts:19-28); the client must format/dealMinor and the server should floor (gap, OQ-1).
9. Assumptions (module)
- PRD: mobile client out of Phase 1. This design is a forward-looking client-side spec; the backend is the contracts authority and the scheduler/late-fee surface is partially wired (see OQ-6/OQ-7).
- Money appears as plain numbers (
Number) in every schema; the blueprint's "integer minor units" rule (Fees.md:63) is not enforced in code — treat server values as decimal units unless a conversion is established (OQ-1). - Payment posting uses an idempotency key on the fees side
(
record-payment.dto.ts:31-34) but the online payments module instead uses a server-generatedtransactionReference(payments.service.ts:215-217) — two idempotency models. InvoiceIssued/PaymentCompleted→emailsqueue (event-queue-map.ts:41,42) → email worker; the FinanceWorker also handles these event types oninvoice-generate(finance.worker.ts:49-68) but no. event is routed to that queue by the map — that branch is effectively idle today (OQ-5).- Reminder + overdue are partially wired: repeatable cron jobs are registered
(
scheduler.service.ts:48-97) butOverdueScanJob.execute/FeeReminderJob.executeare not invoked anywhere, and no worker listens onpayment-reminder(OQ-6). - Late fees KML:
FeeStructure.lateFeeexists (fee-structure.schema.ts:33-34) but nothing consumes it — no late-fee application in code (OQ-7). - Fees endpoints are guarded only by
JwtAuthGuard(fees.controller.ts:24) — no@Permissions()metadata; onlyfees.collectexists inpermissions.constants.ts:31and default Accountant role has only['fees.collect', 'student.read'](role.schema.ts:42-48). Real RBAC is(planned)(OQ-9).
10. Open questions (module-grain; global ledger in 00-shared/12)
| # | Item | Impact |
|---|---|---|
| OQ-1 | Money is plain Number in schemas; blueprint says minor units — no rounding guarantee | Display/format strategy; currency decimals |
| OQ-2 | Overpayment allowed (no cap) — paidTotal >= total flips PAID; excess is never refunded into fees UI | Overpay UX; catch-up ledger values |
| OQ-3 | GET /fees/dues returns only ISSUED/PARTIAL/OUVERDUE; no per-student dues endpoint; q and filtering of structured reports (planned); GET /fees/reports in Fees.md:31 removed | Report UI can only sum locally |
| OQ-4 | Payments module is a separate payments_v2 stack keyed on transactionReference, linked via invoiceId — two parallel collections | Which data feeds a "payments history" screen |
| OQ-5 | Two sumByInvoice implementations — fees sums all statuses, payments sums only completed (fees/repositories/payment.repository.ts:23-35 vs payments/repositories/payment.repository.ts:25-37) — refunded/failed could skew paidAmount | Reconciliation display |
| OQ-6 | Overdue/reminder jobs registered but their .execute() is never called; no payment-reminder worker | Reminder + overdue status "(planned)" |
| OQ-7 | Late fees: field exists, behavior absent | "Late fee" UI deferred |
| OQ-8 | Invoice generation is synchronous today; INVOICE_GENERATE queue + FinanceWorker exist but generation never enqueues | Bulk generation later (planned) |
| OQ-9 | RBAC: no @Permissions on fees endpoints; only fees.collect (+ payments.*, receipts.read) are in ALL_PERMISSIONS | Role-gated UI waits for perms |
| OQ-10 | Receipt print/share has no endpoint — receipt is a stored doc (receipt.schema.ts:9-40); print is client-side representation | Print/share receipt (proposed) |
11. Glossary (this module)
| Term | Meaning |
|---|---|
| Fee structure | fee_structures doc: per class+term, line-item items[], totalAmount, currency, dueDate, lateFee, isActive (fee-structure.schema.ts:8-38) |
| Invoice | invoices doc: snapshot of structure total + discounts, per student, status draft/issued/partial/paid/overdue/cancelled (invoice.schema.ts:7-51) |
| Payment (fees) | payments doc: amount against invoice, idempotencyKey unique, always completed today (payment.schema.ts:22-54) |
| Payment (payments module) | payments_v2 doc: gateway capture, transactionReference, status incl. partially_refunded (payments/schema/payment.schema.ts:28-77) |
| Receipt | receipts doc with receiptNumber RCP-{ts}-{n} (payments.service.ts:180-198) |
| Dues | outstanding invoice: due = totalAmount − paidAmount (fees.service.ts:213-216) |
| Envelope | {success, message, data, meta?, timestamp, requestId} (response-envelope.interceptor.ts:45-61) |
02 — User Personas (Fees Module)
- 1. Admin / Bursar — defines fee structures
- 2. Accountant / Cashier — records payments
- 3. Parent / Guardian — pays and tracks dues
- 4. Org Admin — financial oversight
- 5. Platform Admin (cross-tenant support)
- 6. Role × fees-appearance matrix
Who touches fees, what they can do on the current backend, and what the client should do for them. Permissions reflect
permissions.constants.tsand the system roles inrole.schema.ts:42-48— fees endpoints today are guarded byJwtAuthGuardonly (fees.controller.ts:21-24), so RBAC below is the intended model(planned).
1. Admin / Bursar — defines fee structures
- Job: set up the fee structures for each class and term (tuition, transport, lab, library…), due dates, whether active.
- API surface:
POST/PATCH/DELETE /fees/structures…(fees.controller.ts:28-56). - Constraints: structure requires
name, classId, academicYearId, items[], totalAmount, dueDate; currency defaultsXAF,lateFeedefaults0(fee-structure.schema.ts:9-34;create-fee-structure.dto.ts:26-68). Deletion is soft (base.repository.ts:68-74) — structures remain visible history; invoices already generated are unaffected. - Pain points to solve: creating a season of structures fast (duplicate
structure endpoint is
(planned),IMPLEMENTATION_PLAN.md:205); seeing which structures are active vs archived; avoiding atotalAmountthat doesn't match the sum of line items (server does not validate the sum).
2. Accountant / Cashier — records payments
- Summary: collects cash/mobile-money at the till and posts against an invoice.
- Own surface:
POST /fees/invoices/:id/payments(fees.controller.ts:70-74), reads dues viaGET /fees/dues(fees.controller.ts:76-80). - Role perms: default Accountant role =
['fees.collect', 'student.read'](role.schema.ts:42-48). - Constraints: every payment needs a fresh
idempotencyKey; replay with the same key returns the original payment (never doubles —fees.service.ts:148-152). Payment method must becash|bank_transfer|mobile_money|cheque|other(record-payment.dto.ts:21-25). - Pain points: concurrent keyboards double-submitting (mitigated by idempotency);
accidentally overpaying (no cap server-side); chasing an exact "make paid" state
when partial payments exist (
PARTIALis derived frompaidTotal >= total,fees.service.ts:169-172).
3. Parent / Guardian — pays and tracks dues
- Summary: sees the child's invoices and balance, pays online, reads receipts.
- Surface (read):
GET /fees/students/:studentId/invoices(fees.controller.ts:64-68) — childstudentIdcomes from the Students/Profiles module (no parent-scoped endpoint). Online pay goes through Payments:POST /payments(process-payment.dto.ts), receipts viaGET /payments/receipts(payments.controller.ts:63-67). - Role perms: default Parent =
['student.read'](role.schema.ts:50-56); nofees.*perms — parent fees UI is about the data surfaces the parent role can already reach + gap flagged (OQ-9/12_API). - Constraints: overpay/partial allowed; server derives status from sums. Receipt
is print/shareable client-side from the
receiptsdoc.
4. Org Admin — financial oversight
- Summary: sees all structures, invoices, dues; reconciles odd states.
- Surface today: everything under
/fees/**+/payments/**; no admin-specific fees endpoint (dues report(planned),IMPLEMENTATION_PLAN.md:208discounts,:211statement).
5. Platform Admin (cross-tenant support)
- Summary: support agent;
BaseRepository.scopedFilterbypasses tenant scope for platform admin (base.repository.ts:20-23) — a support-only, read-mostly surface on the same endpoints(planned)across tenants.
6. Role × fees-appearance matrix
| Screen | Admin/Bursar | Accountant | Parent | Org admin |
|---|---|---|---|---|
| Fee structures list/detail | CRUD (fees.controller.ts:28-56) | read | — | read |
| Student invoices | ✓ (:64-68) | ✓ | child only | ✓ |
| Record payment | ✓ | ✓ (till) | via POST /payments | view only |
| Dues | ✓ (76-80) | ✓ | child dues (derived) | ✓ (e.g. aggregation) |
| Payments v2 + receipts | payments.read (81) | — | receipts.read (85) | view |
All rows are guidance until per-endpoint
@Permissions()metadata lands (OQ-9). Fees endpoints today require only a valid JWT.
03 — User Journey (Fees Module)
- 1. Journey: Term kickoff — define fee structures
- 2. Journey: Issue an invoice to a student
- 3. Journey: Collect a payment at the till (fees module)
- 4. Journey: Pay online (payments module) (forward-looking)
- 5. Journey: Reminder + overdue lifecycle
(planned) - 6. Journey: Reconciliation (support)
- 7. Journey: Report on dues (admin)
(proposed)
End-to-end journeys mapped to the implemented API. Every step cites the exact endpoint + rule source. Async/reminder steps marked
(planned)where the worker wiring is incomplete (OQ-6/OQ-7 in 01).
1. Journey: Term kickoff — define fee structures
- Admin opens Fee Structures list →
GET /fees/structures?page&limit(fees.controller.ts:34-38), paginated,meta={page, limit, totalItems, totalPages, hasNext, hasPrevious}(pagination-query.dto.ts:32-55). - Create structure →
POST /fees/structures(fees.controller.ts:28-32) body{name, classId, academicYearId, items[], totalAmount, currency?, dueDate, lateFee?, isActive?}(create-fee-structure.dto.ts:26-68). Server stampsdueDateDate, defaultscurrency='XAF',lateFee=0,isActive=true(fee-structure.schema.ts:27-37). - Server emits
FeeStructureCreated→in-appqueue jobfee-structure-created(fees.service.ts:49-56,event-queue-map.ts:40). - List refreshes (200 envelope, doc incl.
_id, tenantId, createdAt…fromBaseSchema). - (planned) Duplicate a structure for the next class →
POST /fees/structures/:id/duplicate(IMPLEMENTATION_PLAN.md:205).
2. Journey: Issue an invoice to a student
- From a structure detail, admin picks a student →
POST /fees/invoices/generate(fees.controller.ts:58-62) body{studentId, feeStructureId, academicYearId, discounts?}(generate-invoice.dto.ts:24-42). - Server: 404 if structure missing; 409 if an invoice already exists for
(student, structure, year) ("Invoice already exists for this student and
term.") —
fees.service.ts:101-115; unique index backs it (invoice.schema.ts:58-60). totalAmount = max(0, structure.totalAmount − Σ discounts); statusissued,issuedAt=now,dueDatecopied from structure (fees.service.ts:117-130).InvoiceIssuedevent →emailsqueue jobsend-invoice(fees.service.ts:131-138,event-queue-map.ts:41).- Parent sees it under
GET /fees/students/:studentId/invoices(fees.controller.ts:64-68). - (planned) Bulk/async generation for a whole class →
INVOICE_GENERATE(queue.constants.ts:8) + FinanceWorker exist (finance.worker.ts:16-91) butgenerateInvoiceruns synchronously today (OQ-8).
3. Journey: Collect a payment at the till (fees module)
- Cashier opens Dues →
GET /fees/dues?page&limit— only statusissued|partial|overdue(fees.service.ts:190-212); each rowdue = totalAmount − paidAmount(fees.service.ts:213-216). - Taps "Record payment" →
POST /fees/invoices/:id/payments(fees.controller.ts:70-74) body{invoiceId, amount, paymentMethod, reference?, idempotencyKey, paidAt, notes?}(record-payment.dto.ts:11-44). - Server: replay with same key → returns existing payment, no double-count
(
fees.service.ts:148-152); invoice PAID/CANCELLED → 409 (fees.service.ts:155-160). - Status recomputed:
paidTotal >= total → paidelsepartial(fees.service.ts:168-176);PaymentCompleted→emails/send-receipt(fees.service.ts:178-185,event-queue-map.ts:42). - Dues row updates (or disappears when paid).
4. Journey: Pay online (payments module) (forward-looking)
- Parent taps "Pay" on an invoice →
POST /payments(payments.controller.ts:24-28) body{amount, currency?, gateway, gatewayTransactionId?, invoiceId?, invoiceType?, payerId?, payerEmail?, payerName?, description?}(process-payment.dto.ts:5-54). - Server:
transactionReference = TXN-{ts}-{uuid8}(payments.service.ts:215-217), payment stored inpayments_v2withstatus: completedby default (payments.service.ts:36-49). - Receipt auto-created
RCP-{ts}-{n}(payments.service.ts:180-198). - If
invoiceIdgiven →linkToInvoicerecomputes invoicepaidAmount+ status (payments.service.ts:200-213). - Gateway async path:
PATCH /payments/:transactionRef/reconcilebody{status:'success'|'failed'|…}→ completed/failed/pending (payments.controller.ts:36-43,payments.service.ts:113-134). - Refund →
POST /payments/refund(only completed; partial allowed) (payments.controller.ts:30-34,payments.service.ts:74-111). - Receipts read via
GET /payments/receipts[:id](payments.controller.ts:63-73).
5. Journey: Reminder + overdue lifecycle (planned)
- Daily
fee-remindercron0 8 * * *UTC onpayment-reminderqueue (scheduler.service.ts:91-97). FeeReminderJobscansissued|partialinvoices withnow ≤ dueDate ≤ now+3d, enqueuessend-payment-reminderjobs (fee-reminder.job.ts:16-42).- Daily
overdue-scancron0 6 * * *UTC oninvoice-generate(scheduler.service.ts:48-55);FinanceWorkermarksissued|partialinvoices past due asoverdue(finance.worker.ts:75-91). - Status today: crons registered, but
.execute()is never called and no worker listens onpayment-reminder→ deliveries/overdue marking not active (OQ-6). UI must renderoverduechips when the status arrives, and treat reminders as absent until wired.
6. Journey: Reconciliation (support)
GET /payments/invoice/:invoiceId(payments.controller.ts:57-61) vsGET /fees/students/:studentId/invoices— note the two payment collections (feespaymentsvspayments_v2) and two sumByInvoice implementations (fees sums all statusesfees/repositories/payment.repository.ts:23-35, payments sums onlycompletedpayments/repositories/payment.repository.ts:25-37) — OQ-5.- Mismatch handling: mark refunds/partials on the v2 side; invoice
paidAmountrecomputed on next post (linkToInvoice).
7. Journey: Report on dues (admin) (proposed)
- No dedicated report endpoint —
GET /fees/reportsfrom Fees.md:31 is not in the controller (fees.controller.ts:1-81). Client sums the paginated dues screen client-side today; a real report is(planned)(IMPLEMENTATION_PLAN.md:230"Reports";:211per-student statement). - Per-student statement →
GET /fees/students/:studentId/invoices+ per-invoiceGET /payments/invoice/:invoiceIdcomposed client-side(proposed).
04 — Information Architecture (Fees Module)
- 1. Zone: authenticated app → Fees destination
- 2. Route table (module-owned)
- 3. Screen hierarchy
- 4. Modal hierarchy (per
00-shared/05 §5) - 5. Screen relationships
- 6. Quick actions / FAB / context menus
- 7. Information needs per screen (content model)
- 8. Accessibility of IA
Zones, routes, hierarchy, modals, content model. Route table extends 00-shared/05. Backend contract sources:
fees.controller.ts:21-81,payments.controller.ts:17-73.
1. Zone: authenticated app → Fees destination
Fees lives under the authenticated shell (bottom nav / rail, 00-shared/05 §2). Owner of: structures, invoices, payment recording, dues. Payments-v2 (online) screens (transaction list, receipt viewer) are shared with the payments module.
2. Route table (module-owned)
| Route | Screen | Backend | Source |
|---|---|---|---|
/fees | Fees home (dues summary + quick actions) | GET /fees/dues | fees.controller.ts:76-80 |
/fees/structures | Fee structures list | GET /fees/structures | fees.controller.ts:34-38 |
/fees/structures/new | Create structure | POST /fees/structures | fees.controller.ts:28-32 |
/fees/structures/:id | Structure detail | GET /fees/structures/:id | fees.controller.ts:40-44 |
/fees/structures/:id/edit | Edit structure | PATCH /fees/structures/:id | fees.controller.ts:46-50 |
/fees/students/:studentId/invoices | Student invoices | GET /fees/students/:studentId/invoices | fees.controller.ts:64-68 |
/fees/invoices/:id | Invoice detail | (compose: student invoices + GET /payments/invoice/:id) | payments.controller.ts:57-61 |
/fees/invoices/:id/pay | Record payment (till) | POST /fees/invoices/:id/payments | fees.controller.ts:70-74 |
/fees/dues | Dues list | GET /fees/dues | fees.controller.ts:76-80 |
/fees/dues/report | Dues report (proposed) | client-side aggregation | — |
/payments/:transactionRef | Payment detail (v2) | GET /payments/:id | payments.controller.ts:51-55 |
/payments/receipts/:id | Receipt view/print | GET /payments/receipts/:id | payments.controller.ts:69-73 |
/fees/reminders | Reminders (planned) | none (no endpoint) | fee-reminder.job.ts:16-46 |
3. Screen hierarchy
Fees (/fees)
├── Dues (default landing) /fees/dues
│ └── Invoice detail /fees/invoices/:id
│ ├── Record payment /fees/invoices/:id/pay
│ └── Receipt view /payments/receipts/:id (after payment)
├── Fee structures /fees/structures
│ ├── Create /fees/structures/new
│ └── Detail /fees/structures/:id
│ ├── Edit /fees/structures/:id/edit
│ └── Generate invoice (dialog → POST invoices/generate)
├── Student invoices /fees/students/:studentId/invoices → invoice detail
└── Payments (online) — shared with Payments module
├── Transaction list /payments
└── Receipts list /payments/receipts
4. Modal hierarchy (per 00-shared/05 §5)
- Generate invoice dialog (from structure detail): student picker + optional
discounts list →
POST /fees/invoices/generate— 409 handled inline. - Record payment sheet (from invoice detail or dues row): amount, method,
reference, date, notes —
POST /fees/invoices/:id/payments; idempotency key generated client-side per attempt (record-payment.dto.ts:31-34). - Delete structure confirm dialog: soft-delete note ("Existing invoices are
kept"),
DELETE /fees/structures/:id(fees.service.ts:95-98). - Receipt print sheet (
(proposed)): renders the receipt doc, share via share-sheet / print. - Refund dialog (payment detail, v2): amount + reason →
POST /payments/refund(payments.controller.ts:30-34).
5. Screen relationships
| From | Action | To |
|---|---|---|
| Dues row | tap | Invoice detail |
| Dues row | "Collect" | Record payment sheet |
| Structure detail | "Generate invoice" | Generate invoice dialog |
| Invoice detail | "View receipt(s)" | Receipt view |
| Invoice detail (unpaid) | "Pay online" (forward-looking) | Payments module flow |
| Invoice detail | "Payment history" | GET /payments/invoice/:id rows |
6. Quick actions / FAB / context menus
- Fees home: FAB "Collect payment" → scan/select student
(forward-looking)or dues row → record payment sheet. - Structures list: FAB "New structure"; row menu Edit / Duplicate
(
(planned)IMPLEMENTATION_PLAN.md:205) / Delete. - Invoice detail: menu: Refresh, Print/Share receipt
(proposed), (v2) Refund. - Dues screen: filter chips by status (
issued|partial|overdue— the exact set the server queries,fees.service.ts:190-212).
7. Information needs per screen (content model)
- Structure doc (
fee-structure.schema.ts:8-38+BaseSchema):_id, tenantId, name, classId, academicYearId, items[{name,amount}], totalAmount, currency, dueDate, lateFee, isActive, createdAt, updatedAt, version, isDeleted…(base.schema.ts:8-35). - Invoice doc (
invoice.schema.ts:16-51):_id, studentId, feeStructureId, academicYearId, totalAmount, paidAmount, status, dueDate, issuedAt?, discounts[], createdAt…. - Payment doc (fees) (
fees/schemas/payment.schema.ts:22-55):_id, invoiceId, amount, paymentMethod, reference?, idempotencyKey, paidAt, notes?, status(alwayscompletedtoday). - Payment doc (v2) (
payments/schemas/payment.schema.ts:28-77):transactionReference, amount, fee, refundedAmount, currency, gateway, gatewayTransactionId?, gatewayResponse?, status, invoiceId?, invoiceType?, payerId?, payerEmail?, payerName?, description?, metadata?. - Receipt doc (
receipt.schema.ts:7-41):receiptNumber, paymentId, invoiceId?, amount, fee, currency, paymentMethod, payerName?, payerEmail?, description?, issuedAt. - Dues row (
fees.service.ts:213-216):{invoice: InvoiceDoc, due: number}—due = totalAmount − paidAmount.
8. Accessibility of IA
- Every list screen reachable in ≤ 3 taps from
/fees; FABs duplicated as menu items on desktop. - Status is always text + color (never color-only;
09 §9). - Money rendered with currency symbol +
tabularFigures(02 §2 mono). - Deep links
(proposed):studylyon://fees/dues,studylyon://fees/invoices/:id,studylyon://payments/receipts/:id.
05 — Screen Inventory (Fees Module)
- 1. Fees Home
/fees(Dues summary) - 2. Fee Structures List
/fees/structures - 3. Fee Structure Detail
/fees/structures/:id - 4. Create / Edit Fee Structure
/fees/structures/new,/fees/structures/:id/edit - 5. Student Invoices
/fees/students/:studentId/invoices - 6. Invoice Detail
/fees/invoices/:id - 7. Record Payment (till)
/fees/invoices/:id/pay(sheet or page) - 8. Dues List
/fees/dues - 9. Dues Report
/fees/dues/report(proposed) - 10. Payments (online, v2) — shared with Payments module
- 11. Receipts
/payments/receipts,/payments/receipts/:id - 12. Reminders Screen
/fees/reminders(planned) - 13. Payment status chips (shared across screens)
- Shared components used
- Analytics events
(proposed)— 00-shared/10 §8 - Keyboard, landscape, tablet, desktop
Complete inventory of client screens. Every screen maps to a real endpoint (12_API_Mapping.md); screens for unimplemented surfaces are marked. Legend:
(planned)= backend planned, not implemented;(proposed)= client-side composition;(forward-looking)= requires future backend capability.
1. Fees Home /fees (Dues summary)
- Purpose: landing page after Fees destination: outstanding total, status distribution (issued/partial/overdue), quick actions.
- Backend:
GET /fees/dues?page&limit(fees.controller.ts:76-80); onlyissued|partial|overdue(fees.service.ts:190-212). - Elements: stat tiles (total due, count of overdue, count of partial), dues
preview list (top 5 rows: student ref, amount,
due), FAB "Collect payment", chips filter. - Empty: "No outstanding dues"
AppEmptyState. - Analytics:
(proposed)screen_view + tile taps.
2. Fee Structures List /fees/structures
- Purpose: browse all structures (active + archived) for a tenant.
- Backend:
GET /fees/structurespaginated (fees.controller.ts:34-38); no server filtering —sort/q/status filter are client-side today (pagination-query.dto.ts:21-29). - Elements:
AppSearchBar(client filter), status chips (Active/Archived fromisActive,fee-structure.schema.ts:36-37), structure cards: name, class/year refs,totalAmount,currency,dueDate, item count,lateFee. - FAB: "New structure" (
AppFAB.extended). - Row menu: Edit; Delete (soft; confirm dialog); Duplicate
(planned)(IMPLEMENTATION_PLAN.md:205). - Empty: "No fee structures yet" + FAB.
- Analytics:
(proposed)list_loaded, structure_opened, structure_deleted.
3. Fee Structure Detail /fees/structures/:id
- Purpose: full structure view → line items, totals, and the "Generate invoice" action.
- Backend:
GET /fees/structures/:id(fees.controller.ts:40-44); 404 →AppEmptyState"This structure was removed." - Elements: header card (name, active badge, class/year), info rows (
currency,dueDate,lateFee,isActive), line items table (items[{name,amount}],fee-structure.schema.ts:18-22) with sum check (server doesn't validate sum of items ==totalAmount— flag when mismatch), total row, actions: Edit, Generate invoice (dialog), Delete. - Analytics:
(proposed)structure_detail_view, invoice_generate_tap.
4. Create / Edit Fee Structure /fees/structures/new, /fees/structures/:id/edit
- Purpose: capture/amend structure fields incl. dynamic line items.
- Backend:
POST /fees/structures(fees.controller.ts:28-32),PATCH /fees/structures/:id(fees.controller.ts:46-50). - Fields: see 08_Form_Specifications.md §1-§2 (name, classId, academicYearId, items[], totalAmount, currency, dueDate, lateFee, isActive).
- Edit note:
classId/academicYearIdare immutable after create — not inUpdateFeeStructureDto(update-fee-structure.dto.ts:15-54). - Analytics:
(proposed)structure_saved, structure_updated.
5. Student Invoices /fees/students/:studentId/invoices
- Purpose: one student's invoice history (per term).
- Backend:
GET /fees/students/:studentId/invoices(fees.controller.ts:64-68) — non-paginated array (fees.service.ts:142-144), insertion order (invoice.repository.ts:17-21). - Elements: student header (resolved from Students module), invoice cards:
term/structure ref,
totalAmount,paidAmount,due,dueDate, status chip (draft|issued|partial|paid|overdue|cancelled—invoice.schema.ts:7-14). - Empty: "No invoices for this student."
- Analytics:
(proposed)student_invoices_view.
6. Invoice Detail /fees/invoices/:id
- Purpose: authoritative per-invoice state: amount, discounts, dues, payment history, receipt access.
- Backend (composition): invoices from the student-invoices list (no
GET /fees/invoices/:idendpoint exists — OQ-3); payment history viaGET /payments/invoice/:invoiceId(payments.controller.ts:57-61). - Elements: header card (status chip, total/paid/due amounts), discounts block
(
discounts[]snapshot,invoice.schema.ts:46-50), due date (overdue styling), payment history rows (v2 payments:transactionReference, amount, status,gateway), actions: Record payment (till), Pay online(forward-looking), View receipt(proposed)(from v2 receipt). - Empty payments: "No payments recorded yet."
- Analytics:
(proposed)invoice_detail_view, payment_record_tap.
7. Record Payment (till) /fees/invoices/:id/pay (sheet or page)
- Purpose: post an offline payment against an invoice.
- Backend:
POST /fees/invoices/:id/payments(fees.controller.ts:70-74). - Elements: amount field (prefilled with
due), payment method selector (cash|bank_transfer|mobile_money|cheque|other—record-payment.dto.ts:21-25), reference, paid date (AppDatePicker), notes; submit with idempotency key. - States: success → snackbar + receipt hint; 409 (paid/cancelled) → info banner; replay-key behavior never double-posts.
- Analytics:
(proposed)payment_recorded, payment_record_failed.
8. Dues List /fees/dues
- Purpose: everything outstanding with
due = total − paid. - Backend:
GET /fees/duespaginated (fees.controller.ts:76-80);data: [{invoice, due}](fees.service.ts:213-216). - Elements: filter chips (Issued/Partial/Overdue), rows: student ref, structure,
due(mono tabular), status chip,dueDate; row tap → invoice detail; "Collect" inline button → record payment sheet;RefreshIndicator+ infinite scroll (pagination,pagination-query.dto.ts:13-19). - Empty: "All settled — nothing outstanding."
- Analytics:
(proposed)dues_view, dues_collect_tap.
9. Dues Report /fees/dues/report (proposed)
- Purpose: admin aggregation (totals by class/status).
- Backend: none —
GET /fees/reports(Fees.md:31) is not implemented; client composes from paginated dues (acceptable at small scale)(proposed). - Elements:
AppCharts(bar/donut), totals, export (CSV)(proposed). - Analytics:
(proposed)report_view.
10. Payments (online, v2) — shared with Payments module
- Purpose: transaction history, gateway status, refunds.
- Backend:
GET /payments(list, page/limit —payments.controller.ts:45-49),GET /payments/:id(:51-55),GET /payments/invoice/:invoiceId(:57-61),POST /payments/refund(:30-34). - Elements: rows (transactionReference, amount,
gateway, status chip fromPaymentStatusincl.partially_refunded,payments/schema/payment.schema.ts:18-26), detail withgatewayResponse,refundedAmount; refund dialog. - Empty: "No transactions."
11. Receipts /payments/receipts, /payments/receipts/:id
- Purpose: receipt list + printable receipt view.
- Backend:
GET /payments/receipts(payments.controller.ts:63-67),GET /payments/receipts/:id(:69-73). - Elements: receipt doc rendered as document (
receipt.schema.ts:7-41): receiptNumber, payer, amount,fee, currency, paymentMethod, description, issuedAt; actions: Print / Share (share sheet)(proposed). - Empty: "No receipts yet."
12. Reminders Screen /fees/reminders (planned)
- Purpose: see which invoices are about to be / were reminded.
- Backend: none — reminder fan-out exists only as a queue job
(
fee-reminder.job.ts:16-42,send-payment-reminderonpayment-reminder), no list endpoint, no worker. UI deferred until backend wiring (OQ-6). - Elements: read-only list of dues due within 3 days (client-computed mirror of
the job's query
now ≤ dueDate ≤ now+3d,fee-reminder.job.ts:20-23), marked(planned).
13. Payment status chips (shared across screens)
| Status | Chip | Source |
|---|---|---|
invoice draft | neutral | invoice.schema.ts:8 |
invoice issued | primary outline | :9 |
invoice partial | tertiary | :10 |
invoice paid | success | :11 |
invoice overdue | error | :12 |
invoice cancelled | neutral | :13 |
payment pending/processing/failed/refunded/partially_refunded/cancelled | per status | payments/schema/payment.schema.ts:18-26 |
structure isActive | success/neutral | fee-structure.schema.ts:36-37 |
Shared components used
AppCard, AppListTile, AppBadge (chips), AppButton, AppFAB, AppDialog,
AppBottomSheet, AppTextField, AppDropdown, AppDatePicker, AppSearchBar,
AppChips, AppCharts, AppStatTile, AppInfoRow, AppSkeleton,
AppEmptyState, AppErrorState, AppOfflineBanner, AppSnackbar — all from
00-shared/03 (see 07_Component_Library.md for module additions).
Analytics events (proposed) — 00-shared/10 §8
fees.screen_view(dues|structures|structure|invoices|invoice|payment|receipts),
fees.invoice_generate(ok|duplicate), fees.payment_recorded,
fees.payment_failed(code), fees.structure_created/updated/deleted,
fees.report_view. All wired through shared AnalyticsService when it lands.
Keyboard, landscape, tablet, desktop
- Phone: bottom-sheet payment form; tablet: 2-column dues/structure lists
(≥ 840 dp master-detail per 00-shared/02 §8); desktop: navigation rail,
hover states, full-width report
(proposed). - Keyboard:
.nextthrough forms,Ctrl/Cmd+Entersubmit on desktop,N= new structure (desktop).
06 — Screen Specifications (Fees Module)
- 0. Amount display & currency rules (module-wide)
- 1. Fees Home
/fees(Dues summary) - 2. Fee Structures List
/fees/structures - 3. Fee Structure Detail
/fees/structures/:id - 4. Student Invoices
/fees/students/:studentId/invoices - 5. Invoice Detail
/fees/invoices/:id - 6. Record Payment Sheet
/fees/invoices/:id/pay - 7. Dues List
/fees/dues - 8. Payments (v2) list + detail (shared surface)
- 9. Receipts
/payments/receipts,/payments/receipts/:id - 10. Reminders
(planned)/fees/reminders - 11. Cross-cutting: loading / refresh / realtime
Production-grade specs for each Fees screen: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens:
Sp= 00-shared/02 §3,Ty= typography §2,El= elevation §5,R= radius §4,Mo= motion §6. Components reference 00-shared/03 and 07_Component_Library.md (module). Money = server plain-Number values formatted per currency (OQ-1):XAF0 decimals,USD2 decimals, client-sideAppMoneyhelper (see 15_Flutter_Implementation_Guide.md §4).
0. Amount display & currency rules (module-wide)
- Every amount (
totalAmount,paidAmount,due, paymentamount,refundedAmount, receiptamount,fee, structureitems[].amount,totalAmount,lateFee, discounts) is a plainNumberon the wire (fee-structure.schema.ts:19-28,invoice.schema.ts:27-31,fees/schemas/payment.schema.ts:27-28,payments/schemas/payment.schema.ts:33-43,receipt.schema.ts:18-25). - Currency source: structure
currency(defaultXAF,fee-structure.schema.ts:27-28) → copied conceptually to invoice; v2 payments defaultUSD(payments/schemas/payment.schema.ts:42-43), receipts defaultUSD(receipt.schema.ts:24-25). Invoice has no currency field — render from the structure context or tenant config (OQ-1). - Rule: never sum on the client for authoritative balances —
duecomes from the server (fees.service.ts:213-216). Client-side totals only for display (structure items vstotalAmountsanity check). - Formatting:
NumberFormat.currencyfromintl, locale from app;XAF= 0 decimals (CFA has no minor units),USD= 2 decimals; alwaysFontFeature.tabularFigures()(00-shared/02 §2 mono); ≥ 3 trailing digits group with locale separators. - Zero dues: show "Settled" state, never "-0".
1. Fees Home /fees (Dues summary)
Chrome: AppBar "Fees" (titleLarge, El-e1); body = RefreshIndicator +
ListView: AppStatTile row (Total due, Overdue count, Partial count — computed
from the dues page + client grouping), then "Outstanding" section header + dues
preview rows; FAB AppFAB.extended "Collect payment".
Dues preview row:
[student/class ref] [status chip: issued/partial/overdue]
Structure name · due date bodySmall onSurfaceVariant
due amount (mono, tabular) titleMedium; overdue → error + icon schedule
- Meta: from dues rows
{invoice, due}(fees.service.ts:213-216). - States: skeleton; empty
AppEmptyState"All settled — nothing outstanding"; errorAppErrorState+ retry; offline banner + cached dues (TTL 5 min,00-shared/06 §3.3). - A11y: stat tiles =
Semantics(label: "Total due {amount}"); row = oneSemantics(button). - Dark/light: token-only; overdue amounts use
errorin both modes, never color-only (09 §9).
2. Fee Structures List /fees/structures
Chrome: AppBar "Fee structures" + AppSearchBar (client-side filter over the
paginated feed — server ignores q here, pagination-query.dto.ts:21-29);
AppChips filter (All / Active / Archived via isActive,
fee-structure.schema.ts:36-37); body = RefreshIndicator + ListView.builder
infinite scroll (page/limit default 20, max 100, pagination-query.dto.ts:13-19);
FAB "New structure".
Structure card:
[Name] [isActive badge: Active/Archived]
Class · Year bodySmall onSurfaceVariant
totalAmount (mono) · currency titleMedium
Due {dueDate} · {N} items · late fee {lateFee} bodySmall
- Row tap → detail; trailing
AppMenu(Edit / Duplicate(planned)/ Delete). - Delete flow:
AppDialogconfirm — copy must say soft-delete: "Delete this fee structure? Existing invoices are kept." →DELETE /fees/structures/:id(fees.service.ts:95-98) → row fade-outMo.m-base, snackbar "Deleted". - States: skeleton; empty (
AppEmptyState"No fee structures yet"); error 5xx + requestId; offline banner + cache (reference data TTL 24 h,00-shared/06 §3.3). - A11y: card one
Semantics(button)"{name}, {class}, total {amount}"; badges text-first. - Tablet/desktop: master-detail ≥ 840 dp;
Nshortcut = new structure.
3. Fee Structure Detail /fees/structures/:id
Chrome: AppBar "Structure" + trailing menu (Edit / Delete); ScrollView,
sections Sp.24.
Header card (surfaceContainerLow, r-lg, e-1)
name headlineMedium + [Active/Archived badge]
chips: class · year · currency
Info card
AppInfoRow: Total ({totalAmount}), Due date, Late fee ({lateFee}), Status
Line items card
header "Line items ({N})" — AppSectionHeader
rows: item name | amount (mono) (items[] fee-structure.schema.ts:18-22)
sum row: "Sum of items" vs totalAmount → mismatch AppBanner(warning) when
Σitems ≠ totalAmount (server does not validate the sum)
Actions
FilledButton "Generate invoice" → dialog; OutlinedButton "Edit"
Generate invoice dialog — student picker (AppDropdown from Students module),
optional discounts[] editor (name + amount ≥ 0, generate-invoice.dto.ts:13-22):
- Submit →
POST /fees/invoices/generate(fees.controller.ts:58-62). - 409
DUPLICATE_RESOURCE"Invoice already exists for this student and term." (fees.service.ts:107-115) → inlineAppBanner(warning)"An invoice already exists for this student and term" + link to it; dialog stays open. - 404 (structure deleted mid-flight) → close +
AppSnackbar(error)+ back. - Success →
AppSnackbar"Invoice issued" → navigate student invoices.
States: skeleton; 404 → AppEmptyState "This structure was removed or is not
available."; offline → banner + cached detail.
4. Student Invoices /fees/students/:studentId/invoices
Chrome: AppBar "Invoices · {student name}" (name from Students module); body =
RefreshIndicator + list (non-paginated array — render all,
fees.service.ts:142-144).
Invoice card:
[structure/term name] [status chip: draft|issued|partial|paid|
overdue|cancelled — invoice.schema.ts:7-14]
Academic year · Due {dueDate} bodySmall; overdue → error + icon schedule
Total {totalAmount} · Paid {paidAmount} bodyMedium
Due {due - server value} titleMedium mono; 0 → "Settled"
dueis not on this endpoint — the client derives display viatotalAmount − paidAmount(same formula the server uses,fees.service.ts:215) and shows it as derived, or routes to Dues for authoritative value (OQ-3).- Row tap → Invoice Detail.
- States: skeleton; empty
AppEmptyState"No invoices for this student"; error; offline banner + cache (5 min). - A11y: chips text-first; amount announced with currency.
5. Invoice Detail /fees/invoices/:id
Chrome: AppBar "Invoice" + menu (Refresh, Print/Share receipt (proposed));
ScrollView sections Sp.24. Data source: invoice doc (from the student
invoices list — no single-invoice endpoint, OQ-3) + payment history via
GET /payments/invoice/:invoiceId (payments.controller.ts:57-61).
Header card (surfaceContainerLow, r-lg, e-1)
"Invoice" + status chip (status enum invoice.schema.ts:7-14)
Total displaySmall mono (totalAmount)
Paid bodyLarge mono (paidAmount)
Due titleLarge mono (due, server-computed where available)
Amount rows card
AppInfoRow: Issued (issuedAt), Due date (dueDate; overdue → error styling),
Academic year, Structure (ref), Student (ref)
Discounts card (when discounts.length > 0 — invoice.schema.ts:46-50)
rows: discount name | −amount (mono); note "Reduces total at issue time"
Payment history card (v2 payments by invoiceId)
rows: transactionReference · gateway · amount · status chip
(PaymentStatus enum payments/schemas/payment.schema.ts:18-26)
empty: "No payments recorded yet."
Receipt access `(proposed)`
OutlinedButton "View receipt" → /payments/receipts/:id (receipt.schema.ts:9-40)
Actions
FilledButton "Record payment" → sheet (when status issued|partial|overdue)
FilledButton.tonal "Pay online" (forward-looking) → payments module flow
(both hidden when paid|cancelled — server 409s those anyway, fees.service.ts:155-160)
Status chip palette (module-wide):
| Status | Light | Dark | Icon |
|---|---|---|---|
draft | neutral outline | neutral outline | description |
issued | primaryContainer | primaryContainer | |
partial | tertiaryContainer | tertiary-tinted | hourglass |
paid | secondaryContainer | secondary-tinted | check_circle |
overdue | errorContainer | errorContainer | schedule |
cancelled | neutral | neutral | cancel |
Chips always show text label + icon + color (09 §9 — never color-only).
Payment history empty-vs-error: 404 on GET /payments/invoice/:id → "No
payments" (the v2 collection may have none); 5xx → AppErrorState inline with
retry.
States: skeleton (invoice + payments in Future.wait); 404/removed →
AppEmptyState; offline → banner + cached detail.
6. Record Payment Sheet /fees/invoices/:id/pay
Chrome: full-height AppBottomSheet (or page on tablet): "Record payment",
drag handle; fields per 08 §3; FilledButton fullWidth "Record payment".
Flow & states:
- Idle → form prefilled:
amount = due(editable ≥ 0,record-payment.dto.ts:19), method selector (5 options,record-payment.dto.ts:21-25), date = today (paidAtISOIsDateString,record-payment.dto.ts:37-38), optional reference- notes.
submitting→ button spinner, all inputs disabled, anti-double-submit (client + idempotency key as second line of defense).- Idempotency key: generated once per form attempt (
uuid); on retry after a network failure the same key is reused — server replays the original payment instead of duplicating (fees.service.ts:148-152). - Success (200/201): close sheet → snackbar "Payment recorded" →
invoice detail refreshed (server re-derived
paidAmount/status,fees.service.ts:168-176); ifstatus == paidshow success banner "Invoice fully paid". - 409 "Invoice is already paid or cancelled." (
fees.service.ts:155-160) → close sheet,AppBanner(info)"This invoice is already paid/cancelled", refresh. - 409 replay (same key): identical to success — the returned doc is the original payment; treat as success, not error.
- 400: field errors mapped (
details[].message,http-exception.filter.ts:103-108). - Overpay guard (client, advisory): warn when
amount > due("Amount exceeds remaining balance") but allow — server has no cap (OQ-2); show resultingpaidAmountpreview. - Offline: blocked — write-op, no offline queue defined for fees (only reads
cached;
00-shared/07 §10). - A11y: live region announces "Recording payment"; success/error announced.
7. Dues List /fees/dues
Chrome: AppBar "Dues" + AppChips filter (All / Issued / Partial / Overdue —
the exact status set the server queries: fees.service.ts:194-201); body =
RefreshIndicator + ListView.builder infinite scroll (meta.hasNext,
pagination-query.dto.ts:47-54); FAB "Collect payment".
Row (DuesRow):
[student ref] [status chip]
Structure name · due date bodySmall
due amount (mono, tabular) titleMedium — overdue → error + schedule icon
[Collect icon-button] primary, tooltip "Record payment"
duevalue from server (fees.service.ts:213-216); never recompute.- Row tap → Invoice Detail; Collect → Record Payment sheet (route
/fees/invoices/:id/pay). - Infinite scroll: bottom spinner + "end of list" footer; pull-to-refresh resets page 1.
- States: skeleton; empty "All settled — nothing outstanding."; error; offline banner + cached dues (5 min).
- Analytics:
(proposed)dues_collect_tap etc.
8. Payments (v2) list + detail (shared surface)
List /payments: GET /payments?page&limit (payments.controller.ts:45-49);
rows: transactionReference (mono), amount, gateway, status chip (pending,
processing, completed, failed, refunded, partially_refunded, cancelled
— payments/schemas/payment.schema.ts:18-26), createdAt. Filter by status
client-side. Row tap → detail.
Detail /payments/:id: GET /payments/:id (payments.controller.ts:51-55);
info rows: ref, amount, fee, refundedAmount, currency, gateway,
gatewayTransactionId, gatewayResponse, invoiceId, payer (payerName,
payerEmail), description, status. Actions when completed: Refund dialog
(POST /payments/refund, refund-payment.dto.ts:4-19; rules:
payments.service.ts:74-111 — only completed refundable, partial allowed, total
refunded can't exceed amount; errors 404/409 surface as banners).
9. Receipts /payments/receipts, /payments/receipts/:id
List: GET /payments/receipts (payments.controller.ts:63-67); rows:
receiptNumber (mono), amount, paymentMethod, issuedAt; tap → detail.
Detail (print/share receipt (proposed)): GET /payments/receipts/:id
(payments.controller.ts:69-73) rendered as a document card:
┌─────────────────────────────────────────┐
│ RECEIPT RCP-… │ (receiptNumber, mono)
│ {org name} (tenant context) │
│ Amount: {amount} {currency} (mono) │
│ Fee: {fee} (mono) │
│ Method: {paymentMethod} │
│ Payer: {payerName} / {payerEmail} │
│ For: {description} │
│ Invoice: {invoiceId} │
│ Issued: {issuedAt} │
│ Reference: {paymentId} │
└─────────────────────────────────────────┘
- Fields exactly per
receipt.schema.ts:9-40; amount mono tabular; includefee,currency,paymentMethod,payerName,payerEmail,description,issuedAt. - Print: capture via
RepaintBoundary→ share sheet (PDF/image) — no print endpoint exists (OQ-10); QR for paper tracking(forward-looking). - States: skeleton; 404 "Receipt not found." (
payments.service.ts:174-178).
10. Reminders (planned) /fees/reminders
- Backend: none. Mirror of the reminder scan query client-side
(
issued|partialandnow ≤ dueDate ≤ now+3d,fee-reminder.job.ts:16-23) as a read-only list marked(planned); no actions. Replaced by a real endpoint + worker later.
11. Cross-cutting: loading / refresh / realtime
| Screen | Loading | Refresh | Realtime |
|---|---|---|---|
| Fees home | AppSkeleton | pull | (planned) WS invoice.updated (00-shared/07 §8) → re-fetch dues |
| Structures list | skeleton | pull | re-fetch on focus |
| Structure detail | skeleton | pull | — |
| Student invoices | skeleton | pull | re-fetch on focus (payment may land) |
| Invoice detail | skeleton (invoice+payments parallel) | pull | invoice.updated (planned) |
| Dues | skeleton | pull | invoice.updated (planned) |
| Payments list | skeleton | pull | — |
| Record payment | button spinner | — | — |
Error mapping (all screens): 401 → refresh → sessionExpired; 403 →
AppErrorState(403); 404 → empty state; 429 → countdown banner, no auto-retry;
5xx → generic + requestId + retry (00-shared/06 §5).
Keyboard (forms): .next through fields, .done on last, Ctrl/Cmd+Enter
submit (desktop).
Reduced motion: Mo.m-instant everywhere (00-shared/02 §6).
07 — Component Library (Fees Module)
- A. Fees representation
- B. Money input
- D. Dues aggregation
- E. Only elements the API supports
- F. Flutter widget map (module)
- G. Golden tests
Module-specific components used across the Fees screens. Everything else comes from 00-shared/03_Component_Library.md (referenced by name). All amounts render via the shared
AppMoneyhelper (15_Flutter_Implementation_Guide.md §4).
A. Fees representation
MoneyAmount
- Purpose: canonical amount rendering (mono,
tabularFigures). - Props:
amount(num),currency(default from context: structurecurrencydefaultXAFfee-structure.schema.ts:27-28; v2USDpayments/schemas/payment.schema.ts:42-43),style(display|title|label),zeroState("Settled"/"Free"). - Rules:
intl.NumberFormat.currency;XAF0 decimals,USD2; never "-0"; alwaysFontFeature.tabularFigures(). - Flutter:
Text+TextStyle.monospacewrapperAmountText.
StatusChip (AppBadge variant)
- Purpose: invoice / payment / structure status, text + icon + color (never color-only — 09 §9).
- Palette per status (full table in 06 §5); icons
check_circle,schedule,hourglass,mail,cancel,description. - Props:
invoiceStatus?(draft|issued|partial|paid|overdue|cancelled—invoice.schema.ts:7-14),paymentStatus?(PaymentStatuspayments/schemas/payment.schema.ts:18-26),boolStatus?(structureisActive). - Flutter: custom
StatusChip→Container+Icon+Text.
DueRow
- Purpose: dues table row: student ref, structure,
due(server-provided,fees.service.ts:213-216), status chip, inline Collect action. - Props:
invoice,due,onTap,onCollect. - Flutter:
ListTile-basedDueRow(height 56+).
InvoiceCard
- Purpose: invoice summary for student-invoices + detail header.
- Props:
invoice(invoice.schema.ts:16-51),due(derived display),onTap. - Renders total/paid/due burst (
displaySmall/titleLargemono).
StructureCard
- Purpose: structure list row: name, class/year,
totalAmount,currency,dueDate,isActive,items.length,lateFee. - Props:
structure,onTap,onEdit,onDelete,onDuplicate?(planned).
LineItemsTable
- Purpose: structure line items (
items[{name,amount}]fee-structure.schema.ts:18-22) + sum-vs-total warningAppBanner. - Props:
items,totalAmount,currency. - Behaviour: if
Σ items ≠ totalAmountshow warning banner (server never validates the sum).
PaymentHistoryRow
- Purpose: v2 payment row in invoice detail:
transactionReference,amount,gateway, status chip. - Props:
payment(payments/schemas/payment.schema.ts:28-77),onTap.
ReceiptDocument
- Purpose: printable receipt view (
receipt.schema.ts:7-41). - Props:
receipt,actions(Print / Share),qr?(forward-looking). - Wrapped in
RepaintBoundaryfor render-to-image.
DiscountsList
- Purpose:
invoice.discounts[](invoice.schema.ts:46-50): rows name/−amount, caption "Reduces total at issue time". - Props:
discounts,currency.
B. Money input
MoneyField (AppTextField variant)
- Props:
label,value(num?), currency,min(0),max?, validator (≥ 0 numeric), suffix currency code. - Behaviour: numeric keyboard (decimal), groups while typing, clears on focus;
server
@Min(0)perrecord-payment.dto.ts:19— client matches. - A11y: label +
Semantics(value: amount + currency).
DiscountEditor
- Purpose: add/remove
discounts[]in Generate-invoice dialog (generate-invoice.dto.ts:13-22). - Props:
items [{name, amount}],onAdd,onRemove,maxTotal(deducts from structure total). - Validation clientside:
amount ≥ 0(@Min(0)), name required@IsString(generate-invoice.dto.ts:16-20).
PaymentMethodSelector
- Purpose: 5-option selector, exact enum values
(
record-payment.dto.ts:21-25):cash,bank_transfer,mobile_money,cheque,other. - Presented as:
AppBottomSheetpicker or four chips (cash + mobile_money are the till defaults) + "other". - Locale labels i18n keys; raw string sent to server.
D. Dues aggregation
DuesSummaryTiles
- Purpose: fees home stat tiles (total due, overdue count, partial count) computed
from the dues page (client grouping by
invoice.status). - Props:
rows (DuesRow[]). - Note: numbers are derived display; authoritative per-invoice
dueis server (fees.service.ts:213-216).
E. Only elements the API supports
The module renders only fields the schemas/DTOs expose:
- Structure:
name, classId, academicYearId, items[{name,amount}], totalAmount, currency, dueDate, lateFee, isActive. - Invoice:
studentId, feeStructureId, academicYearId, totalAmount, paidAmount, status, dueDate, issuedAt?, discounts[{name,amount}]. - Payment (fees):
invoiceId, amount, paymentMethod, reference?, idempotencyKey, paidAt, notes?, status. - Payment (v2):
transactionReference, amount, fee, refundedAmount, currency, gateway, gatewayTransactionId?, gatewayResponse?, status, invoiceId?, invoiceType?, payerId?, payerEmail?, payerName?, description?, metadata?. - Receipt:
receiptNumber, paymentId, invoiceId?, amount, fee, currency, paymentMethod, payerName, payerEmail, description, issuedAt. Nowaive,balance-string, or per-invoice endpoint fields — those are(planned)(IMPLEMENTATION_PLAN.md:206-211).
F. Flutter widget map (module)
features/fees/presentation/widgets/
├── amount_text.dart # MoneyAmount
├── status_chip.dart # StatusChip (invoice/payment/active)
├── due_row.dart
├── invoice_card.dart
├── structure_card.dart
├── line_items_table.dart
├── discounts_list.dart
├── payment_history_row.dart
├── receipt_document.dart
├── dues_summary_tiles.dart
├── payment_method_selector.dart
├── money_field.dart
└── discount_editor.dart
G. Golden tests
AmountText, StatusChip, InvoiceCard, DueRow, ReceiptDocument,
LineItemsTable — light/dark × 3 sizes (00-shared/03 G). Money locale
(0-major-unit currency) snapshot tests included.
08 — Form Specifications (Fees Module)
- 1. Create Fee Structure Form —
POST /api/v1/fees/structures - 2. Edit Fee Structure Form —
PATCH /api/v1/fees/structures/:id - 3. Generate Invoice Form (dialog) —
POST /api/v1/fees/invoices/generate - 4. Record Payment Form (sheet) —
POST /api/v1/fees/invoices/:id/payments - 5. Payments (v2) forms —
POST /payments,POST /payments/refund - Form-level rules (all)
- Client-side error priority
Every form field-by-field. Validation mirrors class-validator decorators exactly from
src/modules/fees/dto/*.ts; errors arrive as 400VALIDATION_ERRORwitherror.details[].message(http-exception.filter.ts:27-35, 103-108). Client validates inline, submits, and lets server 400 shadow the client.
1. Create Fee Structure Form — POST /api/v1/fees/structures
(create-fee-structure.dto.ts:26-68)
| # | Field | Label | Keyboard | Validation (server) | Client UX |
|---|---|---|---|---|---|
| 1 | name | Name | text | @IsString() required (:28-29) | required; autofocus; TextInputAction.next |
| 2 | classId | Class | — | @IsMongoId() required (:32-33) | AppDropdown (Academics classes); immutable after create |
| 3 | academicYearId | Academic year | — | @IsMongoId() required | AppDropdown; immutable after create |
| 4 | items[] | Line items | required | @IsArray() + @ValidateNested FeeItemDto (:39-43) | LineItemsEditor — dynamic list of {name, amount} |
| 4a | items[].name | Item name | yes | @IsString() (fee-item dto :16-18) | text; required |
| 4b | items[].amount | Item amount | yes | @IsNumber() @Min(0) (:20-23) | MoneyField; ≥ 0; currency context |
| 5 | totalAmount | Total | required | @IsNumber() @Min(0) (create-fee-structure.dto.ts:45-48) | auto-sum hint Σ items; editable; flag mismatch (server won't) |
| 6 | currency | Currency | no | @IsOptional() @IsString(); server default XAF (:50-53, fee-structure.schema.ts:27-28) | AppDropdown default XAF (USD etc. future (proposed)) |
| 7 | dueDate | Due date | required | @IsDateString() (:55-57), server converts to Date (fees.service.ts:46-47) | AppDatePicker; client min = today |
| 8 | lateFee | Late fee | no | @IsOptional() @IsNumber() @Min(0); default 0 | MoneyField optional; unused by backend today (OQ-7) |
| 9 | isActive | Active | no | @IsOptional() @IsBoolean(); default true | AppSwitch default on |
- Server sends
dueDateas ISO string; storeDatein schema (fee-structure.schema.ts:30-31,fees.service.ts:88). - Submit →
POST→ 201 doc → list + snackbar. Errors: 400 → field errors; 5xx → keep form.
2. Edit Fee Structure Form — PATCH /api/v1/fees/structures/:id
(update-fee-structure.dto.ts:15-54)
| # | Field | Required | Server validation | Client UX |
|---|---|---|---|---|
| 1 | name | no | @IsOptional() @IsString() | prefilled |
| 2 | items[] | no | @IsOptional() + nested | prefilled; full replace on PATCH (fees.service.ts:86-89 — whole array swaps) |
| 3 | totalAmount | no | @IsOptional() @IsNumber() @Min(0) | prefilled |
| 4 | currency | no | @IsOptional() @IsString() | prefilled |
| 5 | dueDate | no | @IsOptional() @IsDateString() | picker; past-due warned (server allows) |
| 6 | lateFee | no | @IsOptional() @IsNumber() @Min(0) | prefilled |
| 7 | isActive | no | @IsOptional() @IsBoolean() | prefilled switch |
- Immutable:
classId,academicYearIdare not in the DTO — disabled in edit form with note "Class and academic year cannot be changed after creation". - Submit →
PATCH→ 200 doc; 404 "Fee structure not found." (fees.service.ts:91).
3. Generate Invoice Form (dialog) — POST /api/v1/fees/invoices/generate
(generate-invoice.dto.ts:24-42)
| # | Field | Label | Required | Validation (server) | Client UX |
|---|---|---|---|---|---|
| 1 | studentId | Student | required | @IsMongoId() (:28-29) | AppDropdown (Students module); searchable |
| 2 | feeStructureId | Fee structure | required | @IsMongoId() (:32-33) | hidden — bound to the structure being viewed |
| 3 | academicYearId | Academic year | required | @IsMongoId() (:36-37) | from active academic year selector |
| 4 | discounts[] | Discounts | no | @IsOptional() @IsArray() + nested DiscountDto (:40-42) | DiscountEditor (name + amount ≥ 0) |
| 4a | discounts[].name | name | yes (per item) | @IsString() (:16-18) | text |
| 4b | discounts[].amount | amount | yes (per item) | @IsNumber() @Min(0) (:20-22) | MoneyField ≥ 0 |
- Side effects (server):
totalAmount = max(0, structure.totalAmount − Σ disc)(fees.service.ts:117-119); statusissued,issuedAt: now(fees.service.ts:122-130);InvoiceIssued→emails/send-invoice(event-queue-map.ts:41). - Errors: 404 "Fee structure not found." (
fees.service.ts:105); 409DUPLICATE_RESOURCE"Invoice already exists for this student and term." (fees.service.ts:112-114) → inline info banner, do not close dialog. - Preview: client shows
totalAmountafter discounts before submitting.
4. Record Payment Form (sheet) — POST /api/v1/fees/invoices/:id/payments
(record-payment.dto.ts:11-44)
| # | Field | Label | Required | Validation (server) | Client UX |
|---|---|---|---|---|---|
| 1 | invoiceId | Invoice | required | @IsMongoId() (:14-15) | hidden from route :id — never editable |
| 2 | amount | Amount | required | @IsNumber() @Min(0) (:17-19) | MoneyField, prefilled = due; overpay warn allowed (OQ-2); shows currency |
| 3 | paymentMethod | Method | required | @IsString() + enum list (:22-25) — not @IsEnum, invalid string is 400 validation | PaymentMethodSelector; values cash, bank_transfer, mobile_money, cheque, other (payment.schema.ts:7-13) |
| 4 | reference | Reference | no | @IsOptional() @IsString() | optional; placeholder "Bank ref / MoMo tx id" |
| 5 | idempotencyKey | idempotency key | required | @IsString() (:33-34); unique index (payment.schema.ts:40-41) | hidden, uuid(); field reused on retry (critical: same key = replay, no double-post) — fees.service.ts:148-152` |
| 6 | paidAt | Paid on | required | @IsDateString() (:36-38), server → Date (fees.service.ts:164) | AppDatePicker default today; no future-date cap server-side (2026) |
| 7 | notes | Notes | no | @IsOptional() @IsString() | optional, maxLength 500 (client) |
- Success: 201 payment doc; server sets
status: completedalways (fees.service.ts:162-166) and recomputes invoicepaidAmount+ status (fees.service.ts:168-176). - 409: "Invoice is already paid or cancelled." (
fees.service.ts:159) → close + info banner. - 409 replay: same key → returns original payment: treat as success.
- Offline: blocked (write-op; no offline queue for fees).
5. Payments (v2) forms — POST /payments, POST /payments/refund
(process-payment.dto.ts:5-54, refund-payment.dto.ts:4-19)
Process (online):
| Field | Required | Server validation | Client |
|---|---|---|---|
amount | yes | @IsNumber() @Min(0) | MoneyField |
currency | no | @IsOptional() @IsString(); default USD (:13-15) | locked to tenant/display |
gateway | yes | @IsEnum(PaymentGateway) — stripe,paypal,flutterwave,paystack,razorpay,cash,bank_transfer,cheque (payments/schemas/payment.schema.ts:7-14) | embed/gateway SDK (forward-looking) |
gatewayTransactionId | no | optional string | gateway response |
invoiceId | no | optional string | from invoice detail |
invoiceType | no | optional string | — |
payerId/Email/Name | no | optional strings | payer identity |
description | no | optional string | line item text |
Refund:
| Field | Required | Validation | Client |
|---|---|---|---|
paymentId | yes | @IsString() | hidden |
amount | no | @IsOptional() @IsNumber() @Min(0); server caps refundedAmount + amount ≤ amount (payments.service.ts:81-86) | default = remaining |
reason | no | optional string | text |
- Refund rules: only
completedpayments refundable (payments.service.ts:77-79); full →refunded, partial →partially_refunded(payments.service.ts:87-90).
Form-level rules (all)
- Double-submit: disabled while pending; idempotency key guards rerun.
- Optimistic: no optimistic writes — payment/invoice/structure writes are
server-confirmed (
00-shared/07 §9); only discount/line-item local editing and filters are local. - Undo: line-item removal inside a form = UNDO snackbar (4 s); structure delete = confirm dialog, no undo (soft-delete, no restore endpoint).
- Abandonment: create/edit structure + record-payment keep drafts locally
(proposed)(shared_preferences); back from dirty structure form → "Discard changes?" dialog; record-payment sheet back → "Payment not recorded — discard?" (key NOT reused after explicit discard). - Keyboard: number pad for amounts;
.nextsequence;Ctrl/Cmd+Entersubmits (desktop). - Error copy: business 4xx from envelope
message; codes for the rest (00-shared/07 §11). Never render raw server internals.
Client-side error priority
- 400
VALIDATION_ERROR→ field errors (mapdetails[].messageby field hint,http-exception.filter.ts:103-108). - 404
RESOURCE_NOT_FOUND→ empty/back state. - 409
DUPLICATE_RESOURCE→ info banner (invoice exists / already paid / replay). - 422
BUSINESS_RULE_VIOLATION→ banner (currently unused by fees; reserved). - 429 → countdown; 5xx → keep form + retry.
09 — User Behaviour (Fees Module)
- 1. Expected behaviour (server-contract-driven)
- 2. Common mistakes & countermeasures
- 3. Power & new users
- 4. Mobile & a11y users
- 5. Cross-device & conflict
- 6. Interrupted sessions & retries
- 7. Abandonment
- 8. Frustration points → UX treatments (summary)
- 9. Adoption path (holistic)
Expected behaviour derived from server contracts, plus the failure modes that money UIs invite. Sources:
fees.service.ts,payments.service.ts, fees DTOs, fees/payments schemas.
1. Expected behaviour (server-contract-driven)
- Balance is server truth.
due = totalAmount − paidAmountis produced byGET /fees/dues(fees.service.ts:213-216); the client never recomputes an authoritative balance, only derives display from server values (06 §0). - Payment posts are idempotent. Reusing an
idempotencyKeyreturns the original payment (fees.service.ts:148-152). Double-tap or retry-after-timeout must NOT double-count. - Invoice generation is once per term. Duplicate → server 409
("Invoice already exists for this student and term.",
fees.service.ts:107-115); the UI treats it as "navigate to existing", never as a hard error. - Status is derived/pushed, never client-mutated.
issued → partial/paidbypaidTotal >= totalAmount(fees.service.ts:168-176);overdueis pushed by the finance worker (finance.worker.ts:74-91)(planned). - Overdue rendering should prefer
invoice.status == 'overdue'(invoice.schema.ts:12); money past-due that still holdsissued/partial(worker not yet run) is styled identically client-side but flagged(proposed).
2. Common mistakes & countermeasures
| Mistake | Countermeasure (client) |
|---|---|
| Double-tap "Record payment" | disable while posting + reuse the same idempotencyKey on retry (record-payment.dto.ts:31-34) |
| Wrong amount paid | prefill amount = due; MoneyField >= 0; overpay inline warning (server allows, OQ-2) |
| Posting to a paid/cancelled invoice | hide the action when `paid |
| Misreading a 409 replay as failure | render as success — the returned doc is the original payment (fees.service.ts:148-152) |
Items sum != totalAmount | LineItemsTable warning banner; auto-sum helper on create (server never validates) |
Confusing XAF vs USD | currency locked from context: structures default XAF (fee-structure.schema.ts:27-28), v2 payments default USD (payments/schemas/payment.schema.ts:42-43) |
| Reading refunded/failed sums as paid | use server paidAmount/dues; never hand-sum v2 logs (OQ-5 two sumByInvoice implementations) |
| Editing class/year after create | fields absent from UpdateFeeStructureDto (update-fee-structure.dto.ts:15-45) — disabled + note |
| Offline payment attempt | blocked with guidance (no offline write queue for fees; 00-shared/07 §10) |
3. Power & new users
- Power (cashier): remember last payment method;
paidDate = todaydefault; one-tap "Collect" on dues rows; keyboard-friendly sheet (.next,.done). - Power (admin): structure duplicate
(planned)(IMPLEMENTATION_PLAN.md:205), bulk invoice generation(planned)(today generation is per-student sync, OQ-8). - New (parent): only the child-invoices read surface; every screen explains
"Total / Paid / Due"; "Pay online"
(forward-looking).
4. Mobile & a11y users
- Touch targets ≥ 48 (00-shared/02 §9); rows ≥ 56.
- Amounts with currency symbol/word +
tabularFigures; screen readers announce localized (e.g. "fifty thousand CFA francs"). - Status chips are icon + text + color — never color-only.
- Forms: labels +
Semantics(error); errors announced;MoneyFieldannounces value + currency. - Payment sheet: live-region announce submit progress, success, errors.
5. Cross-device & conflict
- Two terminals posting the same invoice: idempotency prevents doubles; the last
write recomputes
paidAmount/statusfrom totals (fees.service.ts:168-176). - Parent pays online while a cashier posts offline: both recorded;
linkToInvoicesums all (payments.service.ts:200-213). - Lists vs version drift: re-fetch on focus + pull-to-refresh +
invoice.updatedWS(planned)topic (00-shared/07 §8).
6. Interrupted sessions & retries
- Failure mid-
POSTpayment: sameidempotencyKeyretried → replay success. - Failure mid-
POST invoices/generate→ retry hits 409 → navigate to existing invoice with info banner. - Session expiry mid-payment: key retained while the sheet is open; discard invalidates client flow (server key stays inert until reused).
- Background kill mid-edit structure: re-fetch on open; form preserves draft
(proposed).
7. Abandonment
- Structure form dirty → "Discard changes?" dialog;
(proposed)draft toshared_preferences. - Payment sheet back mid-post → "Payment not recorded — discard?"; discarding abandons the key.
- Generate-invoice dialog cancel → nothing sent; invoice exists only after 201.
8. Frustration points → UX treatments (summary)
| Frustration | Treatment |
|---|---|
| Duplicate-invoice 409 | info banner + link to the existing invoice, dialog open |
| Pay on paid/cancelled invoice | close sheet + info banner, never an error dialog |
| Overdue backlog with 5-10 rows | group/filter dues by status; overdue chip distinct from "valid error" |
| $0 balance | "Settled" text, never "−0" |
| Receipt print/share | (proposed) RepaintBoundary render → system share/print |
| No single-invoice endpoint | invoice detail composes from student invoices + GET /payments/invoice/:id (OQ-3) |
9. Adoption path (holistic)
Fees is the highest-trust workflow; order screens by trust: (1) dues at a glance
(fees home → dues list), (2) collect at the till (dues → record payment; cash is
the dominant method), (3) structure management once a term, (4) online pay +
receipts for parents (forward-looking), (5) reports / reminders once server
lands (planned).
10 — Interaction Specification (Fees Module)
- 1. Phone / touch
- 2. Keyboard / desktop / mouse
- 3. Haptics (mobile)
- 4. Motion language (module extension of 00-shared/08 §4)
- 5. Micro-interactions catalogue
- 6. Frustration controls (shared 00-shared/08 §6 + module)
- 7. Special: deep-link & clipboard
- 8. Validation of interaction to server semantics
Platform interaction detail for the Fees screens; extends 00-shared/08. Motion tokens
Mo.*= 00-shared/02 §6;F= focus/touch baseline.
1. Phone / touch
- Dues list: tap row → invoice detail; tap inline "Collect" icon-button
(44 dp, tooltip) → record-payment sheet;
RefreshIndicator; infinite scroll with bottomAppProgress. - Invoices list (student): tap card → detail; pull-to-refresh.
- Record payment: full-height
AppBottomSheet(r-xl, drag handle) with keyboard avoidance (resizeToAvoidBottomInset: true); CTA pinned above keyboard. - Generate invoice:
AppDialog(format): student dropdown + discounts editor; button "Generate invoice". - Receipt view: render +
Shareaction(proposed); long-press number to copy.
2. Keyboard / desktop / mouse
- Amounts: decimal numeric pad;
.nextfield order;Ctrl/Cmd+Entersubmit on forms. - Desktop
N= new structure (list screen);E= edit (detail) — app-level. - Hover states on cards/rows (00-shared/02 §9); hover scale on badges
(proposed). - Right-click row menu for structure rows (Edit / Delete; Duplicate
(planned)).
3. Haptics (mobile)
- Success payment post: light success tap; error/409: warning buzz (short).
- Pull-to-refresh: default haptic; new invoice issued: subtle confirmation.
4. Motion language (module extension of 00-shared/08 §4)
- List transitions
Mo.m-entrancestagger 40 ms; card fade for deletionMo.m-base. - Amount changes animate by counting (
AnimatedSwitcher/IntrinsicCounter) overMo.m-base, only when server-confirmed — never optimistic. - Status chip swaps:
AnimatedSwitcherMo.fast, cross-fade only. - Sheet: standard spring up
Mo.m-base; dialogMo.m-basescale+fade. - Reduced-motion: all
Mo.m-instantstatic; no counting animation, no parallax.
5. Micro-interactions catalogue
| Interaction | Trigger | Feedback |
|---|---|---|
| Record payment submit | tap FilledButton | spinner (icon→spinner), disabled inputs, live-region announce |
| Generate invoice | dialog submit | spinner; then snackbar "Invoice issued" |
| Refresh dues | pull | RefreshIndicator; success silent |
| Overpay attempt (amount > due) | edge | inline tertiary banner "Exceeds remaining balance" + preview resulting paidAmount |
| Amount focus | MoneyField | select-all, hide grouping suffix until blur |
| Copy reference | long-press on transactionReference rows | snackbar "Copied {ref}" |
| Back from dirty sheet | system-back | (proposed) "Discard payment?" dialog |
| 409 duplicate-invoice | submit response | inline AppBanner(info), dialog stays |
| 409 paid/cancelled | submit response | close + snackbar(info) |
6. Frustration controls (shared 00-shared/08 §6 + module)
- Retry always on
AppErrorState; 429 → countdown text, no auto-retry (00-shared/07 §4 api 100/min). - Snackbar error persists ≥ 4 s (00-shared/03 §A); success < 3 s.
- Status changes (e.g. invoice → paid) announce via
Semantics(liveRegion).
7. Special: deep-link & clipboard
(proposed)deeplinks:studylyon://fees/dues,studylyon://fees/structures/:id,studylyon://fees/invoices/:id,studylyon://payments/receipts/:id.- Notification tap → invoice detail (when
invoice.updated/receipt notification lands;(planned)). - Amounts copying: long-press
MoneyAmount→ copy raw number + currency.
8. Validation of interaction to server semantics
| Client interaction | Server semantics (must not diverge) |
|---|---|
| Sheet "Record payment" success | server 201 + recompute paidAmount/status (fees.service.ts:168-176) |
| Sheet retry with same key | replay original doc (fees.service.ts:148-152) |
| Generate invoice success | server 201, _id returned; issuedAt set server-side (fees.service.ts:122-130) |
| Duplicate generation 409 | treated as already-exists navigation (fees.service.ts:107-115) |
Refund amount reduction | server caps refunds ≤ payment (payments.service.ts:81-86) |
| Online payment | transactionReference from server (payments.service.ts:215-217); local refs never sent |
| Any amount displayed | source of truth = server field; formatting client-side only (06 §0; OQ-1) |
11 — Design System Mapping (Fees Module)
- 1. Theme recipes (module)
- 2. Widget → token
- 3. Typography mapping
- 4. Iconography
- 5. Motion tokens (values 00-shared/02 §6)
- 6. Contrast & dark parity
- 7. Breakpoint behaviour
- 8. Theming rules (enforced)
How Fees maps to 00-shared/02 tokens and 00-shared/03 components. No new design tokens; money + finance get semantic aliases only.
1. Theme recipes (module)
| Purpose | Recipe (light / dark) | Used for |
|---|---|---|
| Paid / settled | successContainer / onSuccessContainer | paid chip, settled banners |
| Partial / pending | warningContainer / onWarningContainer | partial chip, "amount due" emphasis |
| Overdue | errorContainer / onErrorContainer + error icon | overdue chip, overdue amounts |
| Issued | primaryContainer / onPrimaryContainer | issued chip, info banners |
| Money emphasis | onSurface mono tabular (02 §2) | totals, dues, receipts |
| Money secondary | onSurfaceVariant | labels, per-item amounts |
| Negative/refund | error | refund amounts |
| Zero state | onSurfaceVariant | "Settled" text |
Semantic roles: success (paid), warning (partial), error (overdue/refund) —
standalone custom roles per 00-shared/02 §1.1.
2. Widget → token
| Widget | Tokens |
|---|---|
| AmountText | bodyLarge–displaySmall mono, tabularFigures, onSurface/onSurfaceVariant |
| StatusChip | *Container role colors, labelMedium, R-r-sm, e-0 |
| InvoiceCard | surfaceContainerLow, R-r-lg, e-1 |
| DueRow | surface, R-r-md, e-0; row height ≥ 56 |
| LineItemsTable | surfaceContainerLow, R-r-md; mismatch banner tertiaryContainer |
| ReceiptDocument | surfaceContainerHigh, R-r-lg, e-1; mono for numbers |
| MoneyField | surfaceVariant fill, outline border, error on error, focus primary |
| Payment sheet | surfaceContainerHigh, R-r-xl, e-5 |
3. Typography mapping
| Token | Use |
|---|---|
displaySmall | invoice total, dues headline amounts |
titleLarge | screen titles (Fees, Dues) |
titleMedium | card titles, section headers, amounts on cards |
bodyMedium | secondary rows (structure meta, payment refs) |
bodySmall | captions ("Reduces total at issue time") |
labelMedium | chips, badges |
mono | all amounts, refs (transactionReference, receiptNumber, idempotencyKey display) |
Numerals: FontFeature.tabularFigures() mandatory on every amount
(00-shared/02 §2).
4. Iconography
Material Symbols (outlined 24) + filled for status:
| Icon | Usage |
|---|---|
payments | fees destination, collect action |
receipt_long | receipt view |
description | invoice doc, draft chip |
mail | issued chip |
hourglass | partial chip |
check_circle | paid chip |
schedule | overdue chip, past-due dates |
cancel | cancelled chip |
wallet | payment method |
content_copy | copy reference |
qr_code | receipt QR (forward-looking) |
Every icon has a text label or tooltip+Semantics (02 §7).
5. Motion tokens (values 00-shared/02 §6)
| Token | Where |
|---|---|
Mo.m-instant | chip cross-fades under reduced motion, value changes |
Mo.m-fast | status chip AnimatedSwitcher, hover tints |
Mo.m-base | payment sheet spring, dialogs, card fade on delete, amount counting |
Mo.m-entrance | list item stagger (40 ms) |
Mo.m-slow | skeleton shimmer |
6. Contrast & dark parity
- All amount text ≥ 4.5:1 (
onSurfaceonsurface); secondary amountsonSurfaceVariant≥ 3:1 minimum per 02 §10. - Status chips: text + icon + container colors must pass 4.5:1 in both modes
(verify
tertiaryContainer/errorContainerpairs). - Dark mode: elevation substitutes for color; no hardcoded light-only styles.
- Reduced-motion & dark QA hooks listed in 14_QA_Checklist.md.
7. Breakpoint behaviour
| Width | Behaviour |
|---|---|
| < 600 dp | single pane; payment sheet full-height; bottom nav |
| 600–839 dp | 2-column lists; sheet as right drawer (proposed) |
| 840–1199 dp | master-detail (dues list + invoice detail), nav rail |
| ≥ 1200 dp | rail + full-width report (proposed); hover states |
8. Theming rules (enforced)
- No color in widget code — tokens only (02 §10).
- Amounts always mono + tabular + currency — no exceptions.
- Status never color-only (09 §9 → icon + text + color).
- Tenant branding overrides
primaryonly (02 §1); fees semantics fixed. AppTheme.light()/dark()unchanged; module adds constants only (AppSpacing,AppRadius,AppMotion).
12 — API Mapping (Fees Module)
- 0. Module-wide request envelope & client policy
- Screen: Fee structures list —
GET /fees/structures - Screen: Fee structure detail —
GET /fees/structures/:id - Screen: Create structure —
POST /fees/structures - Screen: Edit structure —
PATCH /fees/structures/:id - Screen: Delete structure —
DELETE /fees/structures/:id - Screen: Generate invoice —
POST /fees/invoices/generate - Screen: Student invoices —
GET /fees/students/:studentId/invoices - Screen: Record payment (till) —
POST /fees/invoices/:id/payments - Screen: Dues list —
GET /fees/dues - Payments module (online) — shared screens (v2)
- Loading / streaming / realtime
- Client-side error mapping (module)
- Optimistic / undo
- Notifications surface
Exact wire contract for every screen → endpoint. Base
/api/v1; envelope per 00-shared/07. All endpoints fromsrc/modules/fees/controllers/fees.controller.tsandsrc/modules/payments/payments.controller.ts; business rules from the services. Guards:@UseGuards(JwtAuthGuard)on both controllers (fees.controller.ts:24,payments.controller.ts:19) — no RBAC metadata (OQ-9). Tenant from JWT only; never in body (base.repository.ts:20-30).
0. Module-wide request envelope & client policy
| Aspect | Contract |
|---|---|
| Base | https://api.<domain>/api/v1 |
| Headers | Authorization: Bearer <accessToken>; x-request-id; Content-Type: application/json |
| Response | {success, message:"OK", data, meta?, timestamp, requestId} (response-envelope.interceptor.ts:45-61) |
| Error | {success:false, message, error:{code, details?}, timestamp, requestId} (http-exception.filter.ts:73-81); codes VALIDATION_ERROR/UNAUTHENTICATED/PERMISSION_DENIED/RESOURCE_NOT_FOUND/DUPLICATE_RESOURCE/BUSINESS_RULE_VIOLATION/RATE_LIMITED/INTERNAL_SERVER_ERROR (:27-35) |
| Pagination | page (≥1), limit (1–100, default 20), sort (-field), q (pagination-query.dto.ts:5-30); meta = {page, limit, totalItems, totalPages, hasNext, hasPrevious} (:32-55) |
| Idempotency | fees payments: client idempotencyKey; payments-v2: server transactionReference |
| Caching | reads cached client-side (structure 24 h, dues 5 min); no cache on money-write responses |
| Offline | reads from cache; writes blocked (no fees offline queue) |
| Retry | backoff on 5xx/network; no auto-retry on 429 (api tier 100/min) |
Screen: Fee structures list — GET /fees/structures
| Endpoint | GET /api/v1/fees/structures (fees.controller.ts:34-38) |
| Query | page, limit, sort, q (accepted; q unused in service today — client filters) |
| Success | 200 data: [FeeStructureDoc…] + meta — skip/limit (page−1)*limit (fees.service.ts:62-71) |
| Errors | 400 invalid ints (pagination-query.dto.ts:8-19); 5xx |
FeeStructureDoc (fee-structure.schema.ts:8-38, base.schema.ts:8-35):
_id, tenantId, name, classId, academicYearId, items[{name,amount}], totalAmount, currency, dueDate, lateFee, isActive, createdAt, updatedAt, version, ...
Screen: Fee structure detail — GET /fees/structures/:id
| Method | |
|---|---|
| Endpoint | GET /api/v1/fees/structures/:id (fees.controller.ts:40-44) |
| Success | 200 data: FeeStructureDoc |
| Errors | 404 RESOURCE_NOT_FOUND "Fee structure not found." (fees.service.ts:76); 400 invalid id (http-exception.filter.ts:47-48 CastError→VALIDATION_ERROR) |
Screen: Create structure — POST /fees/structures
| Method | |
|---|---|
| Endpoint | POST /api/v1/fees/structures (fees.controller.ts:28-32) |
| Body | {name, classId, academicYearId, items:[{name,amount}], totalAmount, currency?, dueDate, lateFee?, isActive?} (create-fee-structure.dto.ts:26-68); dueDate ISO string → Date (fees.service.ts:45-47) |
| Success | 201 data: FeeStructureDoc (server defaults currency='XAF', lateFee=0, isActive=true) |
| Side effect | emits FeeStructureCreated {feeStructureId, name, classId, academicYearId, totalAmount} → in-app/fee-structure-created (fees.service.ts:49-56, event-queue-map.ts:40) |
| Errors | 400 VALIDATION_ERROR (decorators: IsMongoId, IsArray, IsNumber Min(0), IsDateString, …) |
Screen: Edit structure — PATCH /fees/structures/:id
| Method | |
|---|---|
| Endpoint | PATCH /api/v1/fees/structures/:id (fees.controller.ts:46-50) |
| Body | any subset of {name, items, totalAmount, currency, dueDate, lateFee, isActive} (update-fee-structure.dto.ts:15-54) — classId/academicYearId immutable |
| Success | 200 doc; $set + version+1 (fees.service.ts:85-89, base.repository.ts:57-66) |
| Errors | 404 (fees.service.ts:91); 400 invalid dueDate/items |
Screen: Delete structure — DELETE /fees/structures/:id
| Method | |
|---|---|
| Endpoint | DELETE /api/v1/fees/structures/:id (fees.controller.ts:52-56) |
| Success | 200 {message:"OK"} — soft delete (fees.service.ts:95-98, base.repository.ts:68-74); invoice preserved, no cascade |
| Errors | 404 (fees.service.ts:97) |
Screen: Generate invoice — POST /fees/invoices/generate
| Method | |
|---|---|
| Endpoint | POST /api/v1/fees/invoices/generate (fees.controller.ts:58-62) |
| Body | {studentId, feeStructureId, academicYearId, discounts?:[{name,amount}]} (generate-invoice.dto.ts:24-42) |
| Success | 201 data: InvoiceDoc — totalAmount = max(0, structure.totalAmount − Σ discounts), status:'issued', issuedAt:now, dueDate from structure (fees.service.ts:117-130) |
| Side effect | InvoiceIssued {invoiceId, studentId, feeStructureId, totalAmount, dueDate} → emails/send-invoice (event-queue-map.ts:41) |
| Errors | 404 "Fee structure not found." (fees.service.ts:105); 409 DUPLICATE_RESOURCE "Invoice already exists for this student and term." (fees.service.ts:112-114) + unique index {tenantId,studentId,feeStructureId,academicYearId} (invoice.schema.ts:58-60); 400 |
| Note | Generation is synchronous today (OQ-8) |
InvoiceDoc shape (invoice.schema.ts:16-51): _id, studentId, feeStructureId, academicYearId, totalAmount, paidAmount, status (draft|issued|partial|paid|overdue| cancelled), dueDate, issuedAt?, discounts[], createdAt, updatedAt. Indexes
{tenantId,status} (:57), {tenantId,studentId} (:55).
Screen: Student invoices — GET /fees/students/:studentId/invoices
| Method | |
|---|---|
| Endpoint | GET /api/v1/fees/students/:studentId/invoices (fees.controller.ts:64-68) |
| Success | 200 data: [InvoiceDoc…] non-paginated (no meta, fees.service.ts:142-144) |
| Errors | 400 invalid id; 5xx |
Screen: Record payment (till) — POST /fees/invoices/:id/payments
| Method | |
|---|---|
| Endpoint | POST /api/v1/fees/invoices/:id/payments (fees.controller.ts:70-74) |
| Body | {invoiceId, amount, paymentMethod, reference?, idempotencyKey, paidAt, notes?} (record-payment.dto.ts:11-44); paidAt ISO → Date (fees.service.ts:164) |
| Success | 201 data: PaymentDoc(fees) — status: 'completed' always (fees.service.ts:162-166); invoice paidAmount+status recomputed (fees.service.ts:168-176) |
| Idempotency | idempotencyKey unique (payment.schema.ts:40-41); replay returns the existing payment — no double-count (fees.service.ts:148-152) |
| Side effect | PaymentCompleted {paymentId, invoiceId, amount, status} → emails/send-receipt (event-queue-map.ts:42) |
| Errors | 404 "Invoice not found." (fees.service.ts:154); 409 "Invoice is already paid or cancelled." (fees.service.ts:155-160); 400 validation |
PaymentDoc (fees) shape (fees/schemas/payment.schema.ts:22-55):
_id, tenantId, invoiceId, amount, paymentMethod (cash|bank_transfer|mobile_money| cheque|other), reference?, idempotencyKey, paidAt, notes?, status('completed')…
Screen: Dues list — GET /fees/dues
| Method | |
|---|---|
| Endpoint | GET /api/v1/fees/dues (fees.controller.ts:76-80) |
| Query | page, limit (applied; sort/q not applied by the service) — service hard-filters status ∈ {issued,partial,overdue} (fees.service.ts:192-212) |
| Success | 200 data: [{invoice: InvoiceDoc, due: totalAmount−paidAmount}] (fees.service.ts:213-216); meta |
| Errors | 400 invalid ints; 5xx |
Payments module (online) — shared screens (v2)
| Screen | Endpoint |
|---|---|
| Payment capture | POST /api/v1/payments (body process-payment.dto.ts:5-54) → 201 {payment, receipt}; transactionReference server-generated (payments.service.ts:35-51) |
| Refund | POST /api/v1/payments/refund (body refund-payment.dto.ts:4-19); only completed, partial allowed (payments.service.ts:74-111) |
| Reconcile | PATCH /api/v1/payments/:transactionRef/reconcile body {status} (payments.controller.ts:36-43); success→completed, failed→failed, else→pending (payments.service.ts:121-127) |
| Payment list | GET /api/v1/payments?page&limit (defaults 1/20; :45-49) |
| Payment detail | GET /api/v1/payments/:id (:51-55) |
| By invoice | GET /api/v1/payments/invoice/:invoiceId (:57-61) |
| Receipts list | GET /api/v1/payments/receipts (:63-67) |
| Receipt detail | GET /api/v1/payments/receipts/:id (:69-73) |
PaymentDoc (v2) shape (payments/schemas/payment.schema.ts:28-77):
transactionReference, amount, fee, refundedAmount, currency (default USD), gateway (stripe|paypal|flutterwave|paystack|razorpay|cash|bank_transfer|cheque), gatewayTransactionId?, gatewayResponse?, status (pending|processing|completed| failed|refunded|partially_refunded|cancelled), invoiceId?, invoiceType?, payerId?, payerEmail?, payerName?, description?, metadata?.
ReceiptDoc shape (receipt.schema.ts:7-41): receiptNumber (RCP-{ts}-{n}), paymentId, invoiceId?, amount, fee, currency, paymentMethod, payerName?, payerEmail?, description?, issuedAt? (required).
Loading / streaming / realtime
| Screen | Loading | Streaming | Realtime |
|---|---|---|---|
| Dues / fees home | AppSkeleton | — | (planned) WS invoice.updated (00-shared/07 §8) |
| Structures list | skeleton | — | re-fetch on focus |
| Structure detail | skeleton | — | — |
| Student invoices | skeleton | — | re-fetch on focus |
| Invoice detail | invoice + payments Future.wait | — | invoice.updated (planned) |
| Record payment | button spinner | upload (none) | — |
| Payments list | skeleton | — | — |
| Receipt | doc render | — | — |
Client-side error mapping (module)
| Screen | code | UI |
|---|---|---|
| record payment | 409 DUPLICATE_RESOURCE | info banner (paid/cancelled) or success (replay) |
| generate invoice | 409 | "already exists" banner + link |
| any list/detail | 404 RESOURCE_NOT_FOUND | AppEmptyState |
| create/update | 400 VALIDATION_ERROR | field errors |
| any | 401 → refresh → fail | sessionExpired |
| any | 429 RATE_LIMITED | countdown, no retry |
| any | 5xx | generic + requestId, retry |
Optimistic / undo
- No optimistic mutations anywhere in fees — payment/invoice/refund are all server-confirmed (00-shared/07 §9).
- Undo: only in-form edits (line items, discounts). Delete = confirm dialog, no undo (soft delete only).
Notifications surface
InvoiceIssued → emails/send-invoice, PaymentCompleted → emails/send-receipt
(event-queue-map.ts:41-42); FeeStructureCreated → in-app/fee-structure-created
(:40). Bridge dedups by correlationId + eventType (queue-bridge.service.ts:44-49,69).
Reminder notifications (planned) — see fee-reminder.job.ts:16-46.
13 — State Management (Fees Module)
- 1. FeesHomeCubit (fees home / dues summary)
- 2. DuesListCubit (dues screen — full)
- 3. StructuresListCubit (fee structures)
- 4. FeeStructureDetailCubit
- 5. FeeStructureFormCubit (create + edit)
- 6. StudentInvoicesCubit
- 7. InvoiceDetailCubit (composition)
- 8. RecordPaymentCubit (critical)
- 9. PaymentsCubit (v2 list + refund)
- 10. ReceiptsCubit
- 11. Shared cross-cutting
- 12. Testing hooks (
00-shared/06 §6)
Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) backed by
FeesRepository+PaymentsRepositorycalling the endpoints in 12_API_Mapping.md. Module-wide rule: no optimistic money mutations — every amount/status change is server-confirmed. The only local "success" is the idempotency replay (409 treated as success).
1. FeesHomeCubit (fees home / dues summary)
stateDiagram-v2
[*] --> initial
initial --> loading : Load
loading --> loadedDues : GET /fees/dues (page 1)
loading --> loadedEmpty : meta.totalItems == 0
loading --> error : 5xx / network
loadedDues --> loadedEmpty : items empty (after refresh)
loadedDues --> loadingMore : LoadMore
loadingMore --> loadedDues : append (meta.hasNext)
loadedDues --> loadedDues : Refresh (pull) | ChangeStatusFilter
error --> loading : Retry
- State:
{status, rows: DuesRow[], page, hasNext, filter, summary}. rows={invoice: Invoice, due}exactly fromGET /fees/dues(fees.service.ts:213-216);summary= client-grouped counts (overdue/partial/total due) — display only.- Events:
Load,Refresh,LoadMore,ChangeFilter(status),Retry,PaymentRecorded(invoiceId)→ re-fetch current page (invoice left/updated). - Caching: key
fees:dues:{tenant}:{filter}, TTL 5 min, stale-while-revalidate (00-shared/06 §3.3);RefreshIndicatorbypasses.
2. DuesListCubit (dues screen — full)
stateDiagram-v2
[*] --> initial
initial --> loading : Load
loading --> loaded : rows (meta.hasNext)
loaded --> empty : totalItems == 0
loaded --> loading : Refresh also paginated (reset page=1)
loaded --> loadingMore : LoadMore
loaded --> loaded : CollectDone(invoiceId) → remove/refresh row
error --> loading : Retry
- Events:
Load,Refresh,LoadMore,Retry,ChangeFilter(status),CollectDone(invoiceId, doc)→ re-fetch row list. - Offline: cached dues + banner; actions (Collect) disabled.
3. StructuresListCubit (fee structures)
stateDiagram-v2
[*] --> initial
initial --> loading : Load(page)
loading --> loaded : page data + meta
loading --> errorEmpty : 400
loaded --> loadingMore : LoadMore (page++)
loaded --> loaded : Deleted(id) row removed | FilterChange
loaded --> loaded : Created(doc) prepend
- Server ignores
q/sortonGET /fees/structures(fees.service.ts:60-72) — filter/search are client-side over loaded pages (OQ-3). - Delete: server-confirmed (
DELETE /fees/structures/:id); remove row on 200; roster soft-delete copy in snackbar. - Caching: reference data TTL 24 h;
RefreshIndicatorbypasses.
4. FeeStructureDetailCubit
- State:
{status, structure?, items[], sumMismatch: bool}. - Load:
GET /fees/structures/:id→ doc; computessumMismatch = Σitems ≠ total. - Events:
Load(id),Refresh,EditDone(re-fetch after PATCH),Generated(invoiceDoc)→ snackbar + optionally push student-invoices route. - 409 from generate is handled in a dialog-local GenerateInvoiceCubit below (never a state in this cubit).
- No cache.
5. FeeStructureFormCubit (create + edit)
- State:
{mode, form{name, classId, academicYearId, items:[]LineItem, totalAmount, currency, dueDate, lateFee, isActive}, status: idle|submitting|error}— mirrorscreate-fee-structure.dto.ts:26-68/update-fee-structure.dto.ts:15-54. - Create →
POST /fees/structures→ success → list refresh → detail. - Edit →
PATCH /fees/structures/:id(immutableclassId/academicYearIddisabled). totalAmountauto-synced to Σitems when line items change (editable override);sumMismatchshown.- Errors: 400 →
fieldErrors(mapdetails[].message, keep form); 404 → back. - No optimistic writes. Draft persistence
(proposed)→shared_preferences.
6. StudentInvoicesCubit
- State:
{status, studentId, invoices[]}(non-paginated:fees.service.ts:142-144). - Load:
GET /fees/students/:studentId/invoices. - Row model: invoice fields + derived
due = totalAmount − paidAmount(same formula as server dues,fees.service.ts:215) labeled as derived; chips per status. - Events:
Load,Refresh,InvoiceUpdated((planned)WSinvoice.updated→ re-fetch).
7. InvoiceDetailCubit (composition)
stateDiagram-v2
[*] --> initial
initial --> loadingInvoice : Load(invoiceDoc)
loadingInvoice --> loadingPayments : + GET /payments/invoice/:id
loadingPayments --> loaded : payments[] + invoice
loadingPayments --> loadedSolo : 404 on payments (none recorded)
loaded --> error : payments 5xx
loaded --> loaded : Refresh
- State:
{status, invoice?, payments[]}— invoice doc passed from student/dues lists (noGET /fees/invoices/:idendpoint; OQ-3), payments fromGET /payments/invoice/:invoiceId(payments.controller.ts:57-61). PaymentRecordedevent from PaymentCubit → re-fetch payments + refresh invoice status via list cue.- No cache; re-fetch on focus.
8. RecordPaymentCubit (critical)
stateDiagram-v2
[*] --> idle
idle --> submitting : Submit(form)
submitting --> success(doc) : 201
submitting --> replaySuccess(doc) : 409 (same idempotencyKey already exists)
submitting --> conflictPaid : 409 "already paid or cancelled"
submitting --> failed : 400 / 5xx / network
error --> submitting : Retry (REUSES the same key)
- State:
{status, form{invoiceId, amount, paymentMethod, reference, idempotencyKey, paidAt, notes}, fieldErrors, invoice}. - Key invariant:
idempotencyKeyis generated ONCE per form life-cycle; retries after network failure reuse it — the server replays the original payment (fees.service.ts:148-152) →conflictSuccessandduplicate-ish success, never a double record. - Form mirrors
record-payment.dto.ts:11-44;paymentMethodfromPaymentMethodenum (payment.schema.ts:7-13). - 409 paid/cancelled →
conflictPaid→ close sheet +AppBanner(info)(fees.service.ts:155-160). - Overpay warning: amount > due → inline warning, allowed (OQ-2).
- Offline: submit blocked.
9. PaymentsCubit (v2 list + refund)
- List:
GET /paymentspaginated (page/limit defaults 1/20,payments.controller.ts:45-49); status chips fromPaymentStatus(payments/schemas/payment.schema.ts:18-26). - Detail:
GET /payments/:id+POST /payments/refund(refund-payment.dto.ts); refund rules (payments.service.ts:74-111) → state{status, refundedAmount, remaining}. - Refund submit is server-confirmed; 409 "Only completed payments can be refunded." / "Refund amount exceeds payment amount." → banner.
10. ReceiptsCubit
- List:
GET /payments/receiptspaginated; detail:GET /payments/receipts/:id(payments.controller.ts:63-73). - State:
{status, receipts[], receipt?, printing: bool}— print/share(proposed)viaRepaintBoundarycapture → system share.
11. Shared cross-cutting
AuthCubit401 → refresh; session expiry mid-payment → key retained in sheet state.ConnectivityCubit: offline → cached reads (dues 5 min, structures 24 h, receipts 24 h), writes blocked, offline banner.FeatureFlagsCubitgates(forward-looking)online-pay / QR receipt, and(planned)reminder surfaces per tenant.- Realtime: subscribe
invoice.updatedtopic(planned)→ notify Dues + InvoiceDetail cues to re-fetch (00-shared/06 §3.4).
12. Testing hooks (00-shared/06 §6)
- Unit:
RecordPaymentCubit409→success, 409→conflictPaid, retry-same-key; money formatting/deriveddue; structure-form sum-mismatch; payments refund guards. - Widget: dues loading/empty/error; record-payment states; invoice detail composition incl. payments-404 path; receipts render.
- Every cubit pair has widget-test loading/error/empty permutations.
14 — QA Checklist (Fees Module)
- 1. Functional (server-contract mirrors)
- 2. Concurrency (critical)
- 3. Visual / layout
- 4. Accessibility (00-shared/09)
- 5. Performance
- 6. Offline & network (00-shared/10 §2)
- 7. Tablet / desktop
- 8. Localization
- 9. Permissions
- 10. Dark mode
- 11. Animations
- 12. Security
- 13. Server-backed edge mirrors
- QA scripts
Manual + automated QA for the Fees client, contract-driven. Sources:
fees.service.ts,payments.service.ts,invoice.schema.ts, fees DTOs,finance.worker.ts,fee-reminder.job.ts. Concurrency, partial, overpay, duplicate, and idempotency cases are the money-correctness core.
1. Functional (server-contract mirrors)
Fee structures
-
Create with all fields → 201 doc;
dueDatestored as Date; defaults (currency='XAF',lateFee=0,isActive=true) applied (fee-structure.schema.ts:9-37). -
Create with missing
classId/academicYearId/items/totalAmount/dueDate→ 400VALIDATION_ERRORwithdetails[](create-fee-structure.dto.ts:26-68). -
Edit:
classId/academicYearIdnot accepted (absent fromupdate-fee-structure.dto.ts:15-54). -
Delete = soft delete: row gone from list,
GET /fees/structures/:id→ 404 (base.repository.ts:68-74); no cascade to invoices (OQ). -
Line items sum mismatch vs
totalAmount→ warning banner (server accepts).
Invoice generation
-
Generate → 201;
totalAmount = max(0, structure.totalAmount − Σ discounts)(fees.service.ts:117-119); statusissued,issuedAtset (:122-130). -
Duplicate invoice: same (student, structure, year) → 409
DUPLICATE_RESOURCE"Invoice already exists for this student and term." (fees.service.ts:107-115); UI links to existing invoice. -
Unknown structure → 404 (
fees.service.ts:105). -
Discount with negative amount → 400 (
generate-invoice.dto.ts:20-22). -
Invoice shows on
GET /fees/students/:id/invoicesimmediately (fees.service.ts:142-144).
Payments (fees)
-
Record payment → 201;
status: 'completed'; invoicepaidAmountupdated (fees.service.ts:162-176). -
Partial payment (< due) → invoice
partial; dues list shows remainingdue(fees.service.ts:168-172,213-216). -
Full payment (>= due) → invoice
paid; row leaves dues list (fees.service.ts:194-201filters paid out). -
Overpayment (amount > due) → accepted (no cap); invoice →
paid(fees.service.ts:169-172) — record the resultingpaidAmountdisplay (OQ-2). -
Idempotency replay: re-POST with the SAME
idempotencyKey→ same payment doc,paidAmountunchanged (fees.service.ts:148-152); UI shows success. -
Pay on
paid/cancelledinvoice → 409 "Invoice is already paid or cancelled." (fees.service.ts:155-160). -
Invalid
paymentMethodstring → 400 (enum list inrecord-payment.dto.ts:21-25,payment.schema.ts:7-13).
Payments v2 / receipts
-
POST /payments→{payment, receipt};transactionReferenceformatTXN-{ts}-{uuid8}(payments.service.ts:215-217); receiptRCP-{ts}-{n}(:180-198). -
linkToInvoicerecomputes invoicepaidAmount/statuswheninvoiceIdgiven (payments.service.ts:200-213). -
Refund: only
completed→ allowed; partial →partially_refunded; full →refunded; over-refund → 409 (payments.service.ts:74-111). -
Reconcile:
success→completed,failed→failed, elsepending(payments.service.ts:121-127). -
Receipts list/detail render all fields (
receipt.schema.ts:7-41).
2. Concurrency (critical)
-
Two concurrent posts, same invoice, different keys → both recorded;
paidAmount= sum; status derived from last recompute (fees.service.ts:168-172). -
Same key raced concurrently → exactly one payment created; second resolves
to the same doc (
payment.schema.ts:40-41unique index is the final arbiter;fees.service.ts:148-152first line). -
Cashier + online payment overlap →
linkToInvoicesums both (payments.service.ts:200-213). - Dues list refresh mid-write → no stale overwrite of local state; server response wins.
-
Refund racing a second refund → 409 guard on
refundedAmount(payments.service.ts:81-86).
3. Visual / layout
-
Amounts: mono tabular, grouped, currency symbol;
XAF= 0 decimals,USD= 2 (06 §0); no "-0"; zero = "Settled". - Status chips icon + text + color (06 §5 palette) in light/dark.
- Dues row overdue treatment; chips contrast ≥ 4.5:1 (11 §6).
- Payment sheet on small phones: CTA reachable above keyboard, no clipped fields.
- Tablet master-detail ≥ 840 dp; desktop rail + hover.
4. Accessibility (00-shared/09)
- Amount announcements with currency; live-region on payment submit/success/error.
- Every chip/icon has text label or tooltip; touch targets ≥ 48 dp.
- Focus order: filters → list → FAB; dialogs trap focus; ESC closes.
-
Forms: error text +
Semantics(error); announce validation on submit.
5. Performance
-
Dues/invoice lists paginate (limit 20 default; max 100) — infinite scroll ends
cleanly on
meta.hasNext == false(pagination-query.dto.ts:13-19,47-54). -
Invoice detail: invoice + payments fetched in parallel (
Future.wait), single skeleton. -
Student invoices non-paginated — verify perf with large arrays
(
fees.service.ts:142-144);ListView.builder+RepaintBoundaryon receipt doc. - No layout jank from amount counting animations (reduced-motion path).
6. Offline & network (00-shared/10 §2)
-
Offline: dues/structures/receipts from cache +
AppOfflineBanner; payments and invoice generation blocked with guidance. -
Network drop mid-
POSTpayment → same key retried → replay success (never double). -
429
RATE_LIMITED→ countdown, no auto-retry; 5xx → retry with backoff.
7. Tablet / desktop
-
Master-detail dues → invoice detail; two-pane structure list; payment sheet as
drawer
(proposed);N/Ctrl+Entershortcuts.
8. Localization
-
Money formatting per locale (
intl); currency words in amounts announced localized; i18n keys for statuses/errors (fees.*namespace, 15 §10).
9. Permissions
-
No RBAC on fees endpoints today (
fees.controller.ts:24; OQ-9) — verify UI gates (fees.collectfor Accountant,role.schema.ts:42-48) degrade gracefully when server adds@Permissions; hide actions on 403.
10. Dark mode
11. Animations
- Reduce-motion: instant transitions; counting animation disabled (10 §4).
12. Security
- Tokens in secure storage; no amount/log leakage; no raw server internals in error UI (00-shared/07 §11).
-
idempotencyKeynever reused across different payment attempts (only retries).
13. Server-backed edge mirrors
-
Duplicate invoice — verify 409 → UI navigates to existing invoice
(
fees.service.ts:107-115). -
Reminder idempotency
(planned)—FeeReminderJobenqueues onesend-payment-reminderjob per invoice without a dedup key (fee-reminder.job.ts:25-41); overlapping cron runs would re-enqueue (nojobId/dedup) → when the worker lands, QA must verify no duplicate reminders per invoice per day (OQ-6). -
Overdue marking
(planned)—finance.worker.ts:75-91flipsissued|partialpast-due →overdue; verify chips + dues filtering after the cron fires (repeatable job0 6 * * *UTC,scheduler.service.ts:48-55). -
Late fee —
lateFeefield stored but never applied (OQ-7): UI must not promise automatic late fees. -
CastError on malformed ids → 400
VALIDATION_ERROR"Invalid resource identifier." (http-exception.filter.ts:47-48,91-95).
QA scripts
- Unit: cubits (record-payment 409 branches, derived
due, refund guards) — 13 §12. - Widget: money states (loading/error/empty), chip palette snapshots (light/dark), payment sheet keyboard, receipt doc.
- Golden:
AmountText(XAF/USD/locales),StatusChip(all statuses × 2 themes),ReceiptDocument,InvoiceCard,DueRow(07 §G). - E2E (P0): full loop — admin creates structure → generates invoice → cashier records partial → parent pays remainder online (v2) → receipt printed; duplicate-invoice and double-post paths asserted.
- Integration: cron-triggered overdue + reminder paths once workers are wired
(planned).
15 — Flutter Implementation Guide (Fees Module)
- 1. Folder structure
- 2. Dependencies
- 3. Cubits
- 4. Money formatting (critical)
- 5. FeesRepository (dio)
- 7. Navigation (go_router)
- 8. Offline dues view (read path)
- 9. Record-sheet idempotency implementation
- 10. Theme
- 11. Localization keys
- 12. Testing
- 13. Performance
- Proposals flagged to the team
How to build the Fees feature in the Flutter client on top of 00-shared/11. Forward-looking spec; no client repo exists yet. Money handling is the center of gravity — read §2, §4, §8 first.
1. Folder structure
features/fees/
├── domain/
│ ├── models/
│ │ ├── fee_structure.dart # id, classId, academicYearId, items[],
│ │ │ # totalAmount, currency, dueDate, lateFee, isActive
│ │ ├── line_item.dart # name, amount
│ │ ├── invoice.dart # id, studentId, feeStructureId, academicYearId,
│ │ │ # totalAmount, paidAmount, status (InvoiceStatus),
│ │ │ # dueDate, issuedAt?, discounts[] + derived due
│ │ ├── discount.dart # name, amount
│ │ ├── fee_payment.dart # fees payment doc (invoiceId, amount, paymentMethod,
│ │ │ # reference?, idempotencyKey, paidAt, notes?, status)
│ │ ├── payment_v2.dart # v2 payment (transactionReference, amount, fee,
│ │ │ # refundedAmount, currency, gateway, status, payer…)
│ │ ├── receipt.dart # receiptNumber, amount, fee, currency, paymentMethod,
│ │ │ # payerName/Email, description, issuedAt
│ │ └── dues_models.dart # DuesItem { invoice, due }
│ └── exceptions/fees_exceptions.dart
│ # DuplicateInvoiceException(409), PaymentConflictException(409)
├── data/
│ ├── dto/
│ │ ├── create_fee_structure_dto.dart
│ │ ├── update_fee_structure_dto.dart
│ │ ├── generate_invoice_dto.dart
│ │ ├── record_payment_dto.dart # holds idempotencyKey
│ │ └── payment_v2_dto.dart # process + refund
│ └── repositories/
│ ├── fees_repository.dart
│ └── payments_repository.dart # v2 payments + receipts
└── presentation/
├── cubit/
│ ├── fees_home_cubit.dart
│ ├── dues_list_cubit.dart
│ ├── structures_list_cubit.dart
│ ├── fee_structure_detail_cubit.dart
│ ├── fee_structure_form_cubit.dart
│ ├── student_invoices_cubit.dart
│ ├── invoice_detail_cubit.dart
│ ├── record_payment_cubit.dart # idempotent submit
│ ├── generate_invoice_cubit.dart
│ ├── payments_cubit.dart # v2 list/refund
│ └── receipts_cubit.dart
├── pages/
│ ├── fees_home_page.dart
│ ├── dues_page.dart
│ ├── structures_page.dart
│ ├── fee_structure_form_page.dart
│ ├── student_invoices_page.dart
│ ├── invoice_detail_page.dart
│ ├── record_payment_sheet.dart
│ ├── payments_page.dart
│ └── receipt_page.dart
└── widgets/ (see `07_Component_Library.md §F`)
2. Dependencies
flutter_bloc, dio (AppDio, 00-shared/11 §5), go_router, get_it, intl
(money), cached_network_image (student avatars only), share_plus
(receipt/pdf share (proposed)), printing/pdf (receipt print — verify
license, wrap in ReceiptExporter so swappable). No chart lib in P0 for the dues
report — use fl_chart wrapper (00-shared/03 §AppCharts) when report lands
(proposed).
3. Cubits
Per 13_State_Management.md. The only cubit with real money semantics is
RecordPaymentCubit (idempotency key lifecycle) — keep it pure-Dart and
unit-testable (13 §8).
4. Money formatting (critical)
// Shared money formatter — used by EVERY amount widget (07 §A).
class AppMoney {
static String format(num value, String currency, {bool zeroAs = 'Settled'}) {
if (value == 0) return zeroAs; // never "-0"
final decimals = currency == 'XAF' ? 0 : 2; // CFA = no minor units (OQ-1)
return NumberFormat.currency(
locale: AppLocale.current.languageCode,
symbol: symbolFor(currency),
decimalDigits: decimals,
).format(value);
}
}
- Sources of truth: server fields only (
fees.service.ts:213-216dues;fee-structure.schema.ts:24-28;invoice.schema.ts:27-31;payments/schemas/payment.schema.ts:33-43). Never sum cross-invoice client-side for authoritative balance (OQ-3). - Currency resolution: invoice has no currency field — fall back structure
currency(context) → tenant config →XAF(OQ-1). - Always
FontFeature.tabularFigures()on amountTextstyles.
5. FeesRepository (dio)
class FeesRepository {
Future<Paginated<FeeStructure>> structures({int page = 1, int limit = 20}); // GET /fees/structures
Future<FeeStructure> structure(String id); // GET /fees/structures/:id
Future<FeeStructure> createStructure(CreateFeeStructureDto); // POST /fees/structures
Future<FeeStructure> updateStructure(String id, UpdateFeeStructureDto); // PATCH /fees/structures/:id
Future<void> deleteStructure(String id); // DELETE /fees/structures/:id
Future<Invoice> generateInvoice(GenerateInvoiceDto); // POST /fees/invoices/generate
// throws InvoiceAlreadyExistsException on 409
Future<List<Invoice>> studentInvoices(String studentId); // GET /fees/students/:id/invoices
Future<FeePayment> recordPayment(RecordPaymentDto dto); // POST /fees/invoices/:id/payments
// replay-safe via dto.idempotencyKey
Future<Paginated<DueItem>> dues({int page = 1}); // GET /fees/dues
}
PaymentsRepository: processPayment, refundPayment, reconcile,
byInvoice(invoiceId), payments(page), receipts(page), receipt(id) —
all mapped per 12_API_Mapping.md. AppDio parses envelope + throws
ApiException(code, status); 409 fee-specific exceptions in domain/exceptions.
7. Navigation (go_router)
/fees FeesHome
/fees/dues
/fees/structures
/fees/structures/new
/fees/structures/:id
/fees/structures/:id/edit
/fees/students/:studentId/invoices
/fees/invoices/:id (composed detail; deep-link pay /fees/invoices/:id/pay)
/fees/invoices/:id/pay (payment sheet/push)
/payments (v2 list)
/payments/receipts/:id
Guards: authGuard; role gates fees.collect (Accountant) for record-payment +
collect actions (planned) when server RBAC lands (permissions.constants.ts:31);
payments.refund (:83) gates refund. Deep links (proposed):
studylyon://fees/dues, studylyon://fees/invoices/:id,
studylyon://payments/receipts/:id; root URI table per 00-shared/11 §6.
8. Offline dues view (read path)
- Cache:
feesRepository.dues()results inHive/shared_preferenceskeyedfees:dues:{tenant}:{filter}, TTL 5 min (00-shared/06 §3.3); structure list TTL 24 h; receipts 24 h. - Render offline:
FeesHomeCubit/DuesListCubitshow cached dues +duesnapshot withAppOfflineBanner("Showing cached balances — last synced {ts}"). - Label cached amounts clearly as "may be stale": "Balances update after a payment is recorded." — never present stale money as current truth when offline.
- Writes offline are BLOCKED (
recordPayment,generateInvoice, refund): button disabled + banner. No fees offline write queue defined (00-shared/07 §10). - Pull-to-refresh always bypasses cache; on success supersedes cache; on network
failure keeps cache + error snackbar with
Retry.
9. Record-sheet idempotency implementation
final _key = _IdempotencyKey(); // generated once per sheet lifecycle
Future<void> submit() async {
try {
final res = await _repo.recordPayment(dto.copyWith(idempotencyKey: _key.value));
emit(success(res));
} on ApiException catch (e) {
if (e.code == 'DUPLICATE_RESOURCE' && e.message.contains('already paid'))
emit(conflictPaid()); // paid/cancelled — fees.service.ts:155-160
else if (e.code == 'DUPLICATE_RESOURCE')
emit(success(_fetchOriginal())); // replay of SAME key — fees.service.ts:148-152
else
emit(failed(e));
}
}
- Retry re-uses
_key.value— never regenerates (that would create a duplicate). - Discard sheet → drop key reference (server unique index still prevents future collisions).
10. Theme
AppTheme.light()/dark() unchanged; module adds constants only
(AppSpacing, AppRadius, AppMotion); 11 §2 maps widgets → tokens.
11. Localization keys
fees.home.title, fees.dues.title, fees.dues.total, fees.status.*
(draft/issued/partial/paid/overdue/cancelled/pending/processing/completed/failed/
refunded/partially_refunded/cancelled), fees.amount.due, fees.amount.paid,
fees.amount.settled, fees.payment.recorded, fees.invoice.exists,
fees.invoice.paid_cancelled, fees.money.overpay_warning, fees.offline.stale
(banner), fees.refund.*, fees.error.* code fallbacks (00-shared/07 §11).
12. Testing
- Unit:
AppMoneyformatting (XAF 0-dec, USD 2-dec,number 0 → Settled, locales);RecordPaymentCubit(409 success/replay/paid, retry-same-key); deriveddue; structure-formsumMismatch; refund guards. - Widget: every screen loading/error/empty; payment sheet keyboard; stale-dues banner; invoice detail composition (payments 404 path).
- Golden:
AmountText,StatusChip(all statuses × light/dark),InvoiceCard,DueRow,ReceiptDocument(07 §G). - Integration: structure → generate → partial → reconcile → refund loop; duplicate invoice; double-POST idempotency; offline dues then refresh.
- E2E (P0): full cashier loop on device cloud (
14_QA_Checklist.md).
13. Performance
- Paginated infinite scroll elsewhere rendders the whole dues/structures lists lazily;
ListView.builder+RepaintBoundaryon receipt doc. - Invoice detail
Future.wait(invoice + payments) — single skeleton. - Amount grid
TextFielddoes not rebuild the sheet on each keystroke (controller →ValueNotifier). constconstructors; memoizeAmountTextoutput; counting animation only under normal motion.
Proposals flagged to the team
- When server adds a single-invoice endpoint (
GET /fees/invoices/:id) and a per-student dues endpoint, drop invoice-detail composition (OQ-3). - When
fees.*permission set expands (onlyfees.collect+ payments/readership exist), wire all hidden actions to real perms (OQ-9). - When the payment-reminder worker + endpoint land, enable Reminders screen and
invoice.updatedrealtime (OQ-6). - When late-fee behavior exists (field only today), render computed late fees (OQ-7).
- Analytics wiring waits shared
AnalyticsService(00-shared/10 §8), events per05 §Analytics.
01 — Product Overview (Files Module)
- 1. What it is
- 2. Scope
- 3. PRD native-app exclusion (flagged)
- 4. Key facts (from source)
- 5. Goals
- 6. Non-goals (ponytail)
- 7. Planned roadmap (from
docs/IMPLEMENTATION_PLAN.md)
Source of truth:
src/modules/files/(implemented),src/shared/storage/storage-provider.ts,src/infrastructure/storage/,studylyon-blueprint/02-Architecture/STORAGE_ARCHITECTURE.md(planned direction). Shared conventions: 00-shared/01_Product_Overview, 00-shared/07_API_Conventions.
1. What it is
The Files module is the platform's object-storage gateway: upload, list, download, and delete
binary files (documents, images, exports) while keeping all business logic provider-agnostic.
Storage is behind a single StorageProvider interface; the active driver is chosen by
STORAGE_DRIVER at boot (src/infrastructure/storage/storage.module.ts:11-22).
2. Scope
| In scope (implemented) | Not in scope (gaps, tracked below) |
|---|---|
| Single-file multipart upload | Thumbnail generation (schema fields only) |
| Metadata persistence (filename, mimeType, size, storageKey) | Signed-URL download path (provider methods exist, unused by service) |
| Buffered download + delete (storage + metadata) | Pagination / search / filters on list |
Tenant-scoped CRUD via BaseRepository | File sharing, versioning, folders UI |
| RBAC-permission decorators on all routes | Streaming / Range requests (whole file buffered in RAM) |
3. PRD native-app exclusion (flagged)
00-shared/12_Assumptions_&_Open_Questions.md:12 — the PRD
(PRODUCT_REQUIREMENTS_DOCUMENT.md:144) puts native mobile apps out of Phase 1; roadmap
Phase 3 is a read-only companion. These specs follow the house convention: a full client is
specified (forward-looking), so all Flutter-facing docs in this folder describe screens the
backend endpoints already support. All analytics are (proposed); push/QR affordances are
(forward-looking); anything not yet implemented server-side is (planned).
4. Key facts (from source)
- Routes:
POST /api/v1/files/upload,GET /api/v1/files,GET /api/v1/files/:id,GET /api/v1/files/:id/download,DELETE /api/v1/files/:id(files.controller.ts:29-71). - Permissions:
file.read,file.upload,file.delete(src/modules/rbac/permissions.constants.ts:86-88). Nofile.update/file.share. - Every record is tenant-scoped via
TenantContextService.requireTenantId()(files.service.ts:28) and soft-delete filtered byBaseRepository(repositories/file.repository.ts:9-15). - Storage key convention: R2 uses
<tenantId>/<uuid>--<originalName>(r2.provider.ts:39); local disk usesstorage/<tenantId>/<uuid>--<originalName>(local-storage.provider.ts:24-27); blueprint recommendssl/{tenantId}/{type}/{uuid}(STORAGE_ARCHITECTURE.md:34,(planned)alignment). - No size limit, no MIME allowlist, no TTL/expiry anywhere in code. Upload accepts any
buffer Multer accepts;
mimetypeis client-supplied (files.service.ts:32-33). - Delete = storage object delete + metadata soft-delete (
files.service.ts:66-70).
5. Goals
- Reliable attach/download of school documents from any module screen.
- Tenant isolation by construction (repository scoping + provider key prefix).
- Provider swap without module changes (DI token
STORAGE_PROVIDER,storage-provider.ts:2).
6. Non-goals (ponytail)
- No folders/buckets UI, no preview generation, no dedupe, no virus scan (add when a module
demands them). Thumbnails/etag fields exist in schema (
file.schema.ts:27-43) but are never populated —(forward-looking).
7. Planned roadmap (from docs/IMPLEMENTATION_PLAN.md)
- R2 provider hardening incl.
R2_PUBLIC_URLconfig (Phase 1.1,IMPLEMENTATION_PLAN.md:32;R2_PUBLIC_URLabsent fromsrc/config/env.tstoday)(planned). - Data retention / soft-delete cleanup jobs — 3-day tier noted globally
(
IMPLEMENTATION_PLAN.md:176)(planned). - Signed-URL download and TTL cleanup (
STORAGE_ARCHITECTURE.md:44,66)(planned).
02 — User Personas (Files Module)
- 1. Admin / Super Admin (web-first)
- 2. Office Manager / Exam Cell (web)
- 3. Teacher (web + mobile companion)
- 4. Student / Parent (mobile companion, forward-looking)
- 5. API / Integration Consumer (webhooks, scripts)
- Persona → permission matrix
Personas are derived from the RBAC permission set and the module's endpoint surface (
permissions.constants.ts:86-88,files.controller.ts:29-71). Platform-wide persona notes live in 00-shared/01_Product_Overview; this file only adds the file-handling dimension.
1. Admin / Super Admin (web-first)
- Grants:
file.read,file.upload,file.delete(via role assignment). - Jobs: upload fee circulars, student TC documents, school logos; delete misplaced or sensitive uploads; audit what is stored.
- File behaviour: expects a flat list sorted newest-first (
GET /files, sortedcreatedAt: -1,files.service.ts:48-50); downloads via attachment (browser save dialog,files.controller.ts:61); deletes with a confirm step because deletion is permanent at storage layer (files.service.ts:68-69). - Pain points: no search/pagination today — list is all-or-nothing.
2. Office Manager / Exam Cell (web)
- Grants:
file.upload,file.read(delete often withheld). - Jobs: bulk-attach marksheets and certificates to records; re-upload corrected copies.
- File behaviour: uploads one file at a time (multipart
filefield only,files.controller.ts:31); needs clear success/id of returned record (files.service.ts:36-45); cannot rename or replace — must delete + re-upload (nofile.updatepermission exists). - Pain points: no multi-file upload; large scans held fully in server memory
(
FileInterceptorbuffer → provider buffer →res.send,files.controller.ts:57-63).
3. Teacher (web + mobile companion)
- Grants:
file.upload,file.read. - Jobs: attach homework PDFs, class-photo albums, result PDFs to their context screen.
- File behaviour: uploads from the module screen that owns the record (files are
context-agnostic server-side — the record is a plain document with no folder field;
file.schema.ts:9-25); downloads what students were supposed to submit. - Pain points: no per-context listing server-side; client must filter by its own metadata.
4. Student / Parent (mobile companion, forward-looking)
- Grants:
file.readonly (download/verify; no upload, no delete). - File behaviour: expects tap-to-download with progress, open in system viewer, no
storage URL visible. Authentication on download is the same RBAC
file.read(files.controller.ts:56) — works on any client. - Pain points: buffered download means big PDFs look "stuck"; progress bar must come from the client transport (see 15_Flutter_Implementation_Guide.md).
5. API / Integration Consumer (webhooks, scripts)
- Grants: whatever the service token holds; typically
file.upload+file.read. - File behaviour: POSTs
multipart/form-datawith field namefile(files.controller.ts:31-37), parses the returnedFileRecordJSON, later pullsGET /:id/download(Content-Type echoed from storedmimeType,files.controller.ts:60). - Pain points: no signed-URL flow wired into the service (provider
getSignedUrlexists,storage-provider.ts:25, butFilesServicenever calls it) —(planned).
Persona → permission matrix
| Action | Endpoint | Permission | Admin | Office | Teacher | Student |
|---|---|---|---|---|---|---|
| Upload | POST /files/upload | file.upload | ✅ | ✅ | ✅ | ❌ |
| List/get | GET /files GET /files/:id | file.read | ✅ | ✅ | ✅ | ✅ |
| Download | GET /files/:id/download | file.read | ✅ | ✅ | ✅ | ✅ |
| Delete | DELETE /files/:id | file.delete | ✅ | ❌ | ❌ | ❌ |
03 — User Journeys (Files Module)
- 1. Journey: Upload a document (teacher, office)
- 2. Journey: Download and open (student, parent)
- 3. Journey: Delete a misplaced file (admin)
- 4. Journey: Verify a batch of uploads (office manager)
- 5. Journey: Signed-URL download (planned)
- Journey map (mermaid)
Journeys map to the real endpoint surface (
files.controller.ts:29-71). Multi-file, sharing, and signed-URL steps are(planned)/(forward-looking).
1. Journey: Upload a document (teacher, office)
Context screen (fees/circular/record)
→ tap "Attach file" → POST /api/v1/files/upload (multipart, field `file`, files.controller.ts:29-41)
→ multipart with `file` field → FilesService.upload: tenant scoped (files.service.ts:28),
storage key `<uuid>--<originalName>` (files.service.ts:31),
provider upload → FileRecord persisted (files.service.ts:36-45)
→ 201 → record JSON {id, originalName, mimeType, size, ...}
→ client shows file tile in context list (GET /files refreshed)
- Error paths: 401/403 (guard layer,
(planned)— guards not yet implemented), 500 provider outage; offline → client-side retry queue ((forward-looking)). - Large-file reality: no size cap is enforced server-side today — the entire buffer
round-trips through memory (
FileInterceptormemory storage →upload(input.buffer)→res.send(buffer)). QA and client must assume ~tens of MB max for safety.
2. Journey: Download and open (student, parent)
File tile → tap download
→ GET /api/v1/files/:id/download (files.controller.ts:55-64)
→ service: findById → storage.download(storageFileId) → Buffer (files.service.ts:58-64)
→ headers: Content-Type = stored mimeType, Content-Disposition: attachment; filename="<originalName>"
→ body: whole file → OS open/save
- Not a redirect, not a signed URL — a proxied buffered stream. Fine for <25 MB; for large
exports the blueprint wants streaming (
STORAGE_ARCHITECTURE.md:67)(planned). - No
Rangesupport → no resume for interrupted downloads; client should retry from scratch.
3. Journey: Delete a misplaced file (admin)
File tile → overflow menu → Delete → confirm dialog
→ DELETE /api/v1/files/:id (files.controller.ts:66-71)
→ service: findById (404 if gone) → storage.delete(storageFileId) → repo.softDelete (files.service.ts:66-70)
→ { message: 'File deleted' } → tile removed optimistically; audit note (proposed)
- Semantics: object destroyed at provider, metadata soft-deleted (still visible to
admin-only future audit queries; excluded from normal lists via
BaseRepository).
4. Journey: Verify a batch of uploads (office manager)
GET /api/v1/files → array sorted createdAt desc (files.service.ts:48-50)
→ client groups/filters by its own context metadata (no server filter exists)
→ tap each → detail sheet (GET /files/:id) → download spot-check
(proposed)enhancement: pagination?page&limit,?mimeType=filter — no server support.
5. Journey: Signed-URL download (planned)
POST (planned) or GET /files/:id/signed-url?expiresIn=3600
→ provider.getSignedUrl(storageFileId, ttl) (storage-provider.ts:25)
→ R2 presigned GET (r2.provider.ts:77-86); Appwrite view URL (appwrite-storage.provider.ts:67-74,
NOTE: ignores expiresInSeconds); local returns fake path `/api/v1/files/<id>`
(local-storage.provider.ts:47-51, ponytail shortcut)
→ client downloads direct from provider; expiry → 403 → re-request
Journey map (mermaid)
flowchart LR
A[Context screen] --> B[Upload sheet]
B -->|POST /files/upload| C[(Storage provider)]
C -->|StoredFile| D[FileRecord in Mongo]
D --> E[File tile in list]
E -->|GET /files/:id/download| F[OS open/save]
E -->|DELETE /files/:id| G[(object deleted + soft-delete)]
04 — Information Architecture (Files Module)
- 1. Domain model
- 2. Storage key space
- 3. Screens in IA
- 4. Navigation
- 5. Information flows
- 6. Retention & lifecycle
Global IA and nav patterns: 00-shared/05_Global_Information_Architecture. This file documents only the files domain — its record model, storage key space, and how file screens hang off context screens.
1. Domain model
FileRecord (collection: "files", file.schema.ts:7-8)
├── tenantId (inherited from BaseSchema — every business doc; auto-scoped)
├── isDeleted / timestamps (BaseSchema — soft delete; filtered by BaseRepository)
├── originalName String (user-supplied, used verbatim in Content-Disposition)
├── mimeType String (client-supplied — NOT verified server-side)
├── size Number
├── storageProvider String (STORAGE_DRIVER ?? 'local', files.service.ts:40)
├── storageFileId String (provider object key/id; download by this)
├── storagePath String (= storageFileId today, files.service.ts:41-42)
└── optional, never populated today:
etag, thumbnailFileId, thumbnailStoragePath, thumbnailSize, width, height
(file.schema.ts:27-43) — (forward-looking)
Notes:
width/heightare declarednumberbut typedStringin the schema prop (file.schema.ts:39-43) — schema bug to fix when thumbnails land(planned).- Files are context-agnostic documents: no
folderId, nomodule, noentityReffield. A file's "context" (fee record, student, circular) exists only on the client or in the calling module's own linkage. Any folder/bucket UI is(planned).
2. Storage key space
| Layer | Key shape | Source |
|---|---|---|
| Local driver | storage/<tenantId>/<uuid>--<originalName> | local-storage.provider.ts:24-27 |
| R2 driver | <tenantId>/<uuid>--<originalName> | r2.provider.ts:39 |
| Appwrite driver | bucket APPWRITE_BUCKET_ID, object id = plain UUID | appwrite-storage.provider.ts:38-44 |
| Blueprint target | sl/{tenantId}/{type}/{uuid} | STORAGE_ARCHITECTURE.md:34 (planned) |
- Tenant isolation: mandatory key prefix (R2/local) OR bucket isolation; Appwrite relies on
a single bucket + tenant-scoped metadata only (blueprint: "prefixing is mandatory",
STORAGE_ARCHITECTURE.md:58-59— Appwrite driver does not prefix, gap). storageFileIddoubles asstoragePathin the service (files.service.ts:41-42); thefilenameon the key is the raw original name — no sanitization oforiginalNameanywhere (files.service.ts:31).
3. Screens in IA
Files have no top-level tab. They appear as:
┌─ Module context screen (fees / student profile / circulars …)
│ └─ "Files" section or list (reads GET /files, client-filtered)
│ ├─ Upload sheet (POST /files/upload)
│ ├─ File detail sheet (GET /files/:id)
│ ├─ Download flow (GET /files/:id/download)
│ └─ Delete confirm (DELETE /files/:id)
Client-side grouping key: none server-side; the client may keep files.byContext
local index (see 13_State_Management.md).
4. Navigation
- Deep links
(forward-looking):/files/:id(detail),/files/:id/download(direct download intent). - After upload: stay on context screen, tile appears (optimistic; rollback on failure).
- After delete: tile removed; snackbar with no undo (no restore endpoint)
(proposed: undo).
5. Information flows
| Flow | Read | Write |
|---|---|---|
| Upload | — | Storage upload → Mongo create |
| List | Mongo find (tenant-scoped, createdAt desc) | — |
| Download | Mongo findById → Storage download | — |
| Delete | Mongo findById | Storage delete → Mongo softDelete |
6. Retention & lifecycle
- No TTL on file records today. Global plan: 3-day retention tier for soft-deleted cleanup,
GDPR erasure endpoint, cold storage (
IMPLEMENTATION_PLAN.md:176)(planned). - Temp-upload expiry / cleanup job per blueprint
STORAGE_ARCHITECTURE.md:66(planned).
05 — Screen Inventory (Files Module)
- Legend
- 1. File List — In-Context Section (
/files, embedded) - 2. File Picker / Upload Sheet (bottom sheet)
- 3. Upload Progress (inline card / dialog)
- 4. Download Progress (system notification on mobile / inline on web)
- 5. File Detail Sheet (
/files/:id) - 6. Delete Confirmation Dialog
- 7. Permission-Denied / Locked State
- 8. Image Lightbox
(forward-looking) - Shared components used
- Analytics events (proposed)
- Keyboard, landscape, tablet, desktop
Every screen of the Files module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics. Structure mirrors
design-docs/auth/05_Screen_Inventory.md.
Legend
States = idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission.
Analytics follow {module}.{screen}.{action} (proposed). Auth guards are (planned)
(RBAC decorators exist on every route, guards not yet implemented).
1. File List — In-Context Section (/files, embedded)
| Field | Detail |
|---|---|
| Purpose | List a context's files (student, fee, circular) newest-first |
| Source | GET /api/v1/files → array createdAt desc (files.service.ts:48-50); client filters by its context key |
| Widgets | AppListTile rows: FileTypeIcon + originalName, mimeType, size, date; trailing AppMenu |
| Entry | context screen "Files" section; pull-to-refresh re-fetches |
| States | loading skeleton; empty AppEmptyState; offline AppOfflineBanner; permission (403) → locked tile |
| Permissions | file.read (files.controller.ts:44) |
| Row actions | Download (GET /:id/download), Detail sheet, Delete (admin, file.delete) |
| Analytics | files.list.refresh, files.list.open_detail (proposed) |
| Note | No server pagination/filter — large tenants get full lists; client-side filter only (proposed) |
2. File Picker / Upload Sheet (bottom sheet)
| Field | Detail |
|---|---|
| Purpose | Pick a local file → upload via multipart |
| Route | sheet on context screen; FAB or "Attach" affordance |
| Source | POST /api/v1/files/upload, field file (files.controller.ts:29-41) |
| Permissions | file.upload (files.controller.ts:30) |
| Content | picker row (system picker / camera (forward-looking)), selected-file preview card, Upload CTA |
| States | idle → picking (system) → selected (validate) → uploading (progress, cancelled) → success/error |
| Cancellation | client-side abort; no server cancel endpoint |
| Analytics | files.upload.pick, files.upload.start, files.upload.success, files.upload.failure(code) (proposed) |
3. Upload Progress (inline card / dialog)
| Field | Detail |
|---|---|
| Purpose | Show multipart transfer progress (client-computed; no server progress API) |
| Widgets | AppProgress + filename + size + Cancel text button |
| Terminal states | success → tile appears; error → inline retry (POST re-send, idempotency not server-enforced — retry creates a new record) |
| Note | Server buffers whole file in RAM; UI should treat >25 MB as risky (no cap enforced) |
4. Download Progress (system notification on mobile / inline on web)
| Field | Detail |
|---|---|
| Purpose | Fetch GET /:id/download and deliver bytes to OS |
| Permissions | file.read (files.controller.ts:56) |
| Widgets | AppProgress row/notification; cancel aborts transport, not server work |
| Terminal states | done → open with system viewer (share intent (forward-looking)); fail → retry (no Range/resume) |
| Note | Server sets Content-Disposition: attachment (files.controller.ts:61) — the OS decides save vs open |
5. File Detail Sheet (/files/:id)
| Field | Detail |
|---|---|
| Purpose | Metadata + actions for one file |
| Source | GET /api/v1/files/:id → 404 File not found (files.service.ts:54) |
| Rows | name, type, size, uploaded date, provider id (dev only) |
| Actions | Download; Delete (if file.delete); Copy name |
| States | loading, not-found (deleted elsewhere → leave list), permission |
6. Delete Confirmation Dialog
| Field | Detail |
|---|---|
| Purpose | Confirm permanent object deletion |
| Source | DELETE /api/v1/files/:id → { message: 'File deleted' } (files.controller.ts:66-71) |
| Permissions | file.delete (files.controller.ts:67) |
| Copy | destructive: "Delete file? The stored copy is removed permanently." |
| Terminal | success → optimistic removal; 404 → already gone, remove row silently |
7. Permission-Denied / Locked State
| Field | Detail |
|---|---|
| Purpose | Surfaced when a role lacks file.read / file.upload / file.delete |
| Trigger | 403 from guard (planned); role lookup client-side fallback |
| Widgets | AppEmptyState with lock icon + "Ask admin for access" |
8. Image Lightbox (forward-looking)
- Full-screen view for image mimeTypes after download (buffer →
Image.file); no server thumbnails yet (schema fieldsthumbnailFileIdetc. unpopulated,file.schema.ts:31-37).
Shared components used
AppListTile, AppMenu, AppSnackbar, AppDialog, AppEmptyState, AppSkeleton,
AppOfflineBanner, AppButton, AppBottomSheet, AppProgress, AppAvatar (sender/uploader
contexts). Module-specific: FileTypeIcon, UploadProgressCard, DownloadManagerSheet,
FileTile — defined in 07_Component_Library.md.
Analytics events (proposed)
files.list.{refresh,open_detail}, files.detail.{open,download,delete},
files.upload.{pick,start,success,failure,cancel}, files.download.{start,success,failure,retry},
files.delete.{confirm,cancel,success}.
Keyboard, landscape, tablet, desktop
- Upload sheet: portrait bottom sheet; landscape/tablet → centered dialog ≤ 480 dp.
- File list on tablet: master-detail (list ↔ detail sheet); desktop hover row highlight.
- All sheets keyboard-aware; no text fields except optional caption
(planned).
06 — Screen Specifications (Files Module)
- 1. File List — In-Context Section (
/filesembedded) - 2. File Picker / Upload Sheet
- 3. File Detail Sheet (
/files/:id) - 4. Download Flow
- 5. Delete Confirmation Dialog
- 6. Permission-Denied State (shared)
- 7. Image Lightbox
(forward-looking)
Full behavioral specs for every Files screen. The biggest file in this set: each screen covers layout, states, interactions, events, a11y, motion, and its exact backend source. Shared foundations: 00-shared/03_Component_Library, 00-shared/08_Interaction_&_Motion, 00-shared/09_Accessibility_Baseline. Endpoint references:
files.controller.ts:29-71.
1. File List — In-Context Section (/files embedded)
1.1 Layout
┌─────────────────────────────────────────────┐
│ [Context header] Files (n) [+ Attach] │
├─────────────────────────────────────────────┤
│ ┌─ AppListTile ──────────────────────────┐ │
│ │ [FileTypeIcon] report_2026.pdf 3.2 MB│ │ originalName (1 line, ellipsis)
│ │ application/pdf · 12 Aug │ │ mimeType · relative date
│ │ │ │ trailing: [⋮]
│ └─────────────────────────────────────────┘ │
│ … (rows, newest first) │
├─────────────────────────────────────────────┤
│ [AppSnackbar: "Downloaded report_2026.pdf"] │
└─────────────────────────────────────────────┘
1.2 Data source
GET /api/v1/files— full tenant list,createdAtdesc (files.service.ts:48-50). Nopage/limit, no filter params — client holdsfiles.byContextindex (13_State_Management.md).- Response shape (envelope per
00-shared/07_API_Conventions):FileRecord[]with{id, originalName, mimeType, size, storageProvider, createdAt, updatedAt}.storageFileId/storagePathare server-internal; client must not render them.
1.3 States
| State | Behavior |
|---|---|
| loading | AppSkeleton rows (n=6) |
| success | grouped rows, newest first; size formatted KB/MB (size, file.schema.ts:16) |
| empty | AppEmptyState "No files yet — attach the first one" + Attach CTA |
| error | inline banner + retry (re-run GET /files); offline via AppOfflineBanner |
| permission | 403 → locked row style + "Ask admin for access" (file.read missing) |
| disabled | upload button hidden when file.upload absent; delete menu hidden when file.delete absent |
1.4 Interactions
- Tap row → detail sheet (see §3). Tap download icon → download flow (§4).
⋮menu:Open details·Download·Delete(admin only) ·Copy name.- Pull-to-refresh → re-fetch; optimistic delete rollback on 4xx.
- Context filtering: module screen passes its context key; the section shows only files whose client-side linkage matches.
1.5 Events & analytics (proposed)
files.list.refresh, files.list.open_detail, files.list.download, files.list.delete,
files.list.attach_open.
1.6 A11y & motion
- Rows: combined semantics label "
originalName,mimeType,size"; menu buttonAppMenuexposes actions to a11y. - Motion: rows
m-fastfade/slide on refresh; skeleton shimmer per tokens.
2. File Picker / Upload Sheet
2.1 Layout (bottom sheet on phones; centered card ≤ 480 dp elsewhere)
┌─────────────────────────────────────────────┐
│ Attach file [× close] │
├─────────────────────────────────────────────┤
│ [Choose file…] (system picker) │
│ [Take photo] (forward-looking) │
├─────────────────────────────────────────────┤
│ Selected: [FileTypeIcon] syllabus.pdf │
│ 1.4 MB · application/pdf │
├─────────────────────────────────────────────┤
│ [Upload] (AppButton filled, fullWidth) │
└─────────────────────────────────────────────┘
2.2 Data source
POST /api/v1/files/upload— multipart/form-data, part namefile(files.controller.ts:31-36);ApiConsumes('multipart/form-data')(files.controller.ts:32).- Server derives storage key
${randomUUID()}--${originalName}(files.service.ts:31), persists record (files.service.ts:36-45). - Response: 201
FileRecord(server returns it; status code 201 default Nest POST).
2.3 Validation matrix (client-side; server enforces none today)
| Rule | Value | Verdict |
|---|---|---|
| File required | — | block Upload; sheet error "Choose a file" |
| Size cap | none enforced (gap) | client soft-cap 25 MB w/ warning; hard user block (proposed) |
| MIME allowlist | none server-side | client may gate common doc/image types (proposed) |
| Filename | any (un-sanitized, files.service.ts:31) | display-only; quote in header Content-Disposition (files.controller.ts:61) |
2.4 States
| State | Behavior |
|---|---|
| idle | picker rows only; Upload disabled |
| picking | system picker open (platform sheet) |
| selected | preview card; Upload enabled; re-pick replaces selection |
| uploading | UploadProgressCard (§2.5); Cancel available |
| success | sheet closes → tile appears in list (optimistic insert, rollback on fail) |
| error | inline: 401/403 (auth, (planned)), 413 (planned) size, 500 provider; Retry re-POSTs (new record; no server idempotency) |
2.5 Upload progress card
- Row: filename + animated
AppProgress(percent from client transport, see15_Flutter_Implementation_Guide.md), size,Canceltext button. - Cancel: aborts the client request only; a partially-sent body is discarded by server
(no partial-object cleanup job yet —
(planned)perSTORAGE_ARCHITECTURE.md:66).
2.6 A11y & motion
- Sheet slides up
m-base; live-region announces "Uploading filename — 40%". - All touch targets ≥ 44 dp; picker rows are single
Semantics(button:).
3. File Detail Sheet (/files/:id)
3.1 Layout
┌─────────────────────────────────────────────┐
│ [FileTypeIcon] report_2026.pdf │
│ Name report_2026.pdf │
│ Type application/pdf │
│ Size 3.2 MB │
│ Uploaded 12 Aug 2026, 09:41 │
│ Provider r2 (dev only, hidden in prod) │
├─────────────────────────────────────────────┤
│ [Download] [Delete (admin)] [Copy name] │
└─────────────────────────────────────────────┘
3.2 Data source
GET /api/v1/files/:id(files.controller.ts:49-53) → 404File not found(files.service.ts:54) when missing or soft-deleted (repository filters).
3.3 States & behavior
| State | Behavior |
|---|---|
| loading | skeleton card |
| not found | sheet closes; row removed from list; snackbar "File no longer available" |
| permission | 403 → lock + admin note |
4. Download Flow
4.1 Behavior
- Tap download →
GET /api/v1/files/:id/download(files.controller.ts:55-64). - Service:
findById→storage.download(storageFileId)→{buffer, mimeType, filename}(files.service.ts:58-64). - Response headers:
Content-Type: <stored mimeType>(files.controller.ts:60),Content-Disposition: attachment; filename="<originalName>"(files.controller.ts:61). - Body: raw bytes (
res.send(result.buffer),files.controller.ts:63) — proxied, fully buffered; no redirect, no signed URL (those are(planned)).
4.2 Client states
| State | Behavior |
|---|---|
| starting | progress row appears (0%) — client-computed from transport |
| progress | % bytes received; cancel allowed (no server resume; no Range support) |
| success | web: browser save; mobile: system notification + "Open" share (forward-looking) |
| failure | inline retry; 404 → row dropped; 403 → permission state |
4.3 Large-file guidance
- Buffered server path caps practical size ~25 MB (RAM × N concurrent). Blueprint calls for
streaming large exports (
STORAGE_ARCHITECTURE.md:67)(planned). - Client: treat >25 MB with an explicit "Large file — may take a while" confirm
(proposed).
5. Delete Confirmation Dialog
5.1 Behavior
- Trigger:
file.deleteholders only (files.controller.ts:67). - Copy: "Delete
report_2026.pdf? The stored copy is removed permanently." - Confirm →
DELETE /api/v1/files/:id→{ message: 'File deleted' }(files.controller.ts:66-71). - Service sequence:
findById(404 if already gone) →storage.delete(storageFileId)→repo.softDelete(id)(files.service.ts:66-70). - Object is hard-deleted at provider; metadata is soft-deleted (excluded from lists, retained for audit) — surfaced in copy as "removed permanently" for the stored copy.
5.2 States
| State | Behavior |
|---|---|
| confirming | destructive AppDialog (red confirm, m-fast) |
| pending | button spinner; double-tap ignored |
| success | optimistic row removal + snackbar; no undo endpoint (proposed: restore) |
| error | 403 (permission revoked) / 500 → dialog error inline; row stays |
6. Permission-Denied State (shared)
- Rendered when guard 403 (
(planned)) or role lookup fails client-side. - Variants:
file.read→ locked list;file.upload→ hidden Attach;file.delete→ hidden Delete. Copy: "Ask an admin to grant file access."
7. Image Lightbox (forward-looking)
- mimeType
image/*rows get a preview action: download buffer → local preview. - No server thumbnail pipeline (schema has
thumbnailFileId/thumbnailStoragePath/ thumbnailSize/width/heightfields,file.schema.ts:31-43, never populated).
Cross-cutting
- Envelope/errors: standard success/error envelopes (
00-shared/07_API_Conventions;common/interceptors/response-envelope.interceptor.ts,common/filters/http-exception.filter.ts). - Auth: every route carries
@Permissions(...); guards are(planned). - Tenant: all reads/writes tenant-scoped by
BaseRepository+TenantContextService(files.service.ts:28,file.repository.ts:9-15).
07 — Component Library (Files Module)
- 1. FileTypeIcon
- 2. FileTile (list row)
- 3. UploadSheet
- 4. UploadProgressCard
- 5. DownloadManagerSheet
- 6. DeleteConfirmDialog
- 7. EmptyFilesState
- 8. StorageInfoBar
(proposed) - Shared component reuse (no re-spec)
Module-specific components. Shared components are authoritative in 00-shared/03 and are reused, not re-specified. All visuals use 00-shared/02_Design_Tokens.
1. FileTypeIcon
- Purpose: mimeType → glyph/color (
image/*,application/pdf,application/vnd.*,application/zip,text/*,video/*, fallback generic). - Input:
mimeType: String(fromFileRecord.mimeType,file.schema.ts:13). - Behavior: deterministic map; no network call (no icon URLs — files have no web-visible URL today).
- A11y: decorative (
excludeFromSemantics); row label carries the name.
2. FileTile (list row)
- Composition:
FileTypeIcon+ two-line text (originalName,mimeType · size · relative date) + trailingAppMenu. - Data:
FileRecord(list shape:id, originalName, mimeType, size, createdAt). - States: normal / selected / locked (no
file.read) / offline-dimmed. - Interactions: tap → detail sheet; menu → download, delete (conditional), copy name.
- Semantics: single button label "
originalName,mimeType,size".
3. UploadSheet
- Composition:
AppBottomSheetshell; picker rows (system file picker; camera(forward-looking)); selected-file preview; primaryUploadAppButton. - Behavior: enforces client-side validation matrix (08_Form_Specifications.md);
emits
pick(file)/upload(file)/cancel. - States: idle / picking / selected / uploading / error.
4. UploadProgressCard
- Composition: filename +
AppProgress(indeterminate until first bytes, then %), bytes sent/total,Canceltext button. - Behavior: driven by transport
onSendProgress(dio) orXMLHttpRequestupload events; cancel aborts request (server keeps nothing partial — no cleanup job yet,(planned)). - States: 0% connecting (indeterminate) → percent → success (auto-dismiss) / error (Retry re-POSTs — new record, no idempotency).
5. DownloadManagerSheet
- Purpose: single place for active/completed downloads (mobile) or inline rows (web).
- Composition: list of
DownloadTaskrows:FileTypeIcon, name,AppProgress, pause(forward-looking)/ cancel; completed rows show "Open" / "Share"(forward-looking). - Behavior: per-task state
queued → downloading → completed | failed | cancelled(see 13_State_Management.md). - Note: server has no Range support — pause/resume means restart
(forward-looking).
6. DeleteConfirmDialog
- Composition:
AppDialogdestructive variant; message "DeleteoriginalName? The stored copy is removed permanently." - Behavior: confirm →
DELETE /files/:id; spinner on confirm; error inline; no undo.
7. EmptyFilesState
AppEmptyStatevariant: icon, "No files yet", caption, optional "Attach first file" CTA (shown only withfile.upload).
8. StorageInfoBar (proposed)
- Optional tenant quota line (server has no quota API — purely informational from
client-aggregated
sizesums; only if a module asks for it).
Shared component reuse (no re-spec)
AppListTile, AppMenu, AppButton, AppBottomSheet, AppDialog, AppSnackbar,
AppSkeleton, AppEmptyState, AppOfflineBanner, AppProgress, AppChips,
AppAvatar — definitions and contracts in 00-shared/03_Component_Library.md.
08 — Form Specifications (Files Module)
Files module has exactly one form: the upload sheet. Everything else is pick→confirm→list. Shared form/validation conventions: 00-shared/03 (components), 00-shared/07_API_Conventions (envelope + errors).
1. Upload Form (multipart)
1.1 Contract
| Aspect | Value | Source |
|---|---|---|
| Endpoint | POST /api/v1/files/upload | files.controller.ts:29-41 |
| Content-Type | multipart/form-data | ApiConsumes, files.controller.ts:32 |
| Field name | file (single, binary) | FileInterceptor('file'), files.controller.ts:31 |
| Success | 201 + FileRecord | service returns persisted doc, files.service.ts:45 |
| Auth | file.upload | files.controller.ts:30 |
1.2 Fields
| Field | Type | Required | Server validation | Notes |
|---|---|---|---|---|
file (binary) | Buffer | yes | none (gap) | client supplies originalname, mimetype, size; server stores verbatim (files.service.ts:32-33) |
There is no DTO — dto/upload-file.dto.ts does not exist in the module (only
controller/service/module/schema/repository). Server accepts anything Multer parses.
1.3 Client-side validation matrix
| Rule | Client behavior | Server today |
|---|---|---|
| File selected | block Upload until selected | — |
| Size 0 bytes | block: "Empty file" | accepts (gap) |
| Size > 25 MB | warn + confirm (soft cap) (proposed) | accepts (no limit anywhere — gap; consider multer limits.fileSize (planned)) |
| MIME allowlist | optional gating on common types (proposed) | accepts any mimetype string (spoofable — QA item, 14_QA_Checklist.md) |
| Filename sanity | strip control chars client-side only | stored raw into key ${uuid}--${originalName} (files.service.ts:31) and into Content-Disposition header (files.controller.ts:61) |
1.4 Error mapping
| HTTP | Meaning | UI |
|---|---|---|
| 400 | malformed multipart (missing file part) | sheet error "Choose a file" |
| 401 / 403 | unauthenticated / no file.upload | auth flow / permission state (guards (planned)) |
| 413 | payload too large — when a multer limit lands (planned) | size copy + suggestion |
| 500 | storage provider failure (storage.upload throws, files.service.ts:29-34) | "Upload failed — retry" (no server idempotency; retry = new record) |
| offline | transport error | AppOfflineBanner + retry queue (forward-looking) |
1.5 Flow states
idle → selected → submitting (progress %; cancel) → success | error → retry
- Success: sheet closes, tile inserts optimistically into list (rollback on fail).
- Cancel: abort client request only; server discards partial body (no partial cleanup
job yet —
STORAGE_ARCHITECTURE.md:66(planned)).
1.6 A11y
- Live region announces progress; cancel reachable via keyboard; error text announced;
focus returns to
Uploadon failure.
2. Future forms (planned)
- Signed-URL request:
GET /files/:id/signed-url?expiresInSeconds=3600→ providergetSignedUrl(fileId, expiresInSeconds)(storage-provider.ts:25) — R2 presigns (r2.provider.ts:77-86); Appwrite currently ignores the TTL (appwrite-storage.provider.ts:69-74); local returns a fake path (local-storage.provider.ts:47-51). Not wired into the service yet. - Caption/metadata form: no
file.updatepermission exists (permissions.constants.ts:86-88) — skip until a module needs it.
09 — User Behaviour (Files Module)
- 1. Uploading
- 2. Downloading
- 3. Managing the list
- 4. Permissions
- 5. Offline & flaky networks
- 6. Trust & safety posture (UI copy guidance)
Behavioral expectations and patterns for file handling, derived from the server's actual semantics (buffered upload/download, no limits, no TTL). Global behavior baselines: 00-shared/08_Interaction_&_Motion, 00-shared/09_Accessibility_Baseline.
1. Uploading
- One file at a time. Server accepts a single
filepart (FileInterceptor('file'),files.controller.ts:31). Multi-file = repeated POSTs; UI queues sequential uploads(proposed). - Immediate feedback. After picking, users expect: preview (name/type/size), progress, then the tile appearing in list. Progress must be client-computed (transport-level); the server exposes no progress API.
- Retry mindset. A failed POST re-sends the whole body (no idempotency key, no multipart resume). Users accept this for ≤ 25 MB; for larger files warn first (08_Form_Specifications.md §1.3).
- No replace. Users cannot overwrite a file — delete + re-upload (no
file.update). UI must make this visible before they try.
2. Downloading
- Attachment-first. Server always responds
Content-Disposition: attachment(files.controller.ts:61), so "open" on mobile really means "download then open". Do not promise in-place preview of PDFs without a local save step. - Buffered = slow-feeling. The whole object round-trips through the API memory
(
files.service.ts:62); large files show a long "connecting" phase. Show indeterminate progress until first bytes. - No resume. Interrupted downloads restart from zero (no
Rangeheaders served). Cancel is client-side only. - Trust the mimeType… carefully. The
Content-Typeheader is the client-suppliedmimeTypeechoed back (files.controller.ts:60). A spoofed type will mislabel the file in the OS. UI should render the icon from the same field (consistent, even if wrong).
3. Managing the list
- Newest first is the server order (
files.service.ts:48-50) — matches expectation of "just uploaded appears on top". - Context grouping is client-side. Users think files belong to a fee record or student; the server doesn't know. If a module links files to entities, it must maintain that linkage itself (local index, 13_State_Management.md).
- Deletion is permanent. Stored copy is hard-deleted at provider
(
files.service.ts:68); metadata soft-deleted. No undo, no restore endpoint — confirm dialog must say so.
4. Permissions
- Users without
file.readsee no files; withoutfile.uploadno Attach control; withoutfile.deleteno delete action. Role mapping is the only signal today (guards(planned)— roles come from RBAC,permissions.constants.ts:86-88).
5. Offline & flaky networks
- Upload/download failures: inline retry; never silently drop. Offline: banner + disabled
actions, outbox queue
(forward-looking). - Signed-URL expiry UX
(planned): if a direct provider URL 403s (expired TTL), client re-requests a fresh URL — never show the raw 403.
6. Trust & safety posture (UI copy guidance)
- Warn on large uploads and on downloads > 25 MB.
- On download of unknown types (
application/octet-streamor unusual mime), show "Open with caution"(proposed)— server does no scanning. - No TTL exists today: files persist until deleted (global 3-day retention tier is
(planned),IMPLEMENTATION_PLAN.md:176).
10 — Interaction Specification (Files Module)
- 1. Opening the upload sheet
- 2. Picking a file
- 3. Upload progress
- 4. Upload failure
- 5. Download interaction
- 6. Delete confirmation
- 7. Long-press & swipe
(proposed) - 8. Drag & drop (desktop)
(forward-looking) - 9. Offline behavior
- 10. Pull-to-refresh
- 11. Motion budget summary
Micro-interactions and motion for file flows. Motion tokens (
m-fast,m-base), haptics, and platform patterns are authoritative in 00-shared/08_Interaction_&_Motion — this file applies them to the files domain.
1. Opening the upload sheet
- Trigger: "Attach" CTA →
AppBottomSheetslides up (m-base, 280 ms, ease-out); scrim dims list. - Tablet/desktop: centered dialog ≤ 480 dp, scale+fade (
m-fast). - Haptic: light (
HapticFeedback.lightImpact) on open (mobile).
2. Picking a file
- System picker is a platform-owned transition (no app animation).
- On selection return: preview card animates in (
m-fastfade+slide 8 dp);UploadCTA becomes enabled (color shift per tokens — no bounce). - Re-pick replaces the preview (old card out
m-fast, new in).
3. Upload progress
- Progress card:
AppProgressfills 0→100% with per-frame value from transportonSendProgress; percent label; bytes counter1.2 MB / 3.2 MB. - Indeterminate until first progress event (server buffering) —
AppProgressindeterminate variant, not a frozen 0%. - Cancel: press state ripple; card collapses out (
m-fast); snackbar "Upload cancelled". - Success: card → green check 200 ms → auto-dismiss; tile inserts into list with
m-baseslide + highlight pulse (2 ×m-fast).
4. Upload failure
- Card flips to error color; inline "Retry" button (re-POST). Error shake on the card
(8 dp,
m-fast) per 00-shared/08. - Snackbar alternative only when the card is dismissed.
5. Download interaction
- Tap download → progress row appears under the tile (inline, no full-screen dialog for single files).
- Percent from
onReceiveProgress; indeterminate until first bytes. - Done (web): browser download UI takes over — show snackbar "Downloaded
name". - Done (mobile): system notification; tap → share/open sheet
(forward-looking). - Failure: row shows error + Retry (restarts from 0 — no resume).
6. Delete confirmation
- Menu →
AppDialogdestructive variant: title + file name + redDelete(danger color token),Cancelghost. - Confirm: button spinner (no double-tap), row removal animates out (
m-basecollapse), snackbar "File deleted". No undo action(proposed: restore later). - Dismiss: scrim tap / back / ESC cancels without animation of the row.
7. Long-press & swipe (proposed)
- Long-press row → multi-select mode
(proposed); swipe-left → delete shortcut on admin rows(proposed)— requires server multi-delete(planned)before shipping.
8. Drag & drop (desktop) (forward-looking)
- Drag a local file onto the list/section → drop zone highlight → auto-open UploadSheet prefilled. Browser-only; no server change.
9. Offline behavior
- Offline banner pins to top of list; upload/download actions disabled with explanation
(no server reachability). Reconnect → retry queue drains
(forward-looking).
10. Pull-to-refresh
- List re-fetch (
GET /files) with standardRefreshIndicator; no haptic unless completion > 500 ms (platform default).
11. Motion budget summary
| Interaction | Token | Note |
|---|---|---|
| Sheet open/close | m-base | bottom slide |
| Row insert/remove | m-base | collapse + highlight pulse |
| Preview card swap | m-fast | fade + 8 dp slide |
| Error shake | m-fast | 8 dp |
| Dialog confirm | m-fast | scale+fade |
11 — Design System Mapping (Files Module)
- 1. Token usage
- 2. Component → system mapping
- 3. Semantics by state (files-specific)
- 4. Density & adaptive
- 5. a11y mapping (baseline 00-shared/09)
Maps every Files screen/component to the shared design system. Tokens are authoritative in 00-shared/02_Design_Tokens, system mapping in 00-shared/04_Design_System_Mapping. This file adds only files-specific usage.
1. Token usage
| Token group | Files usage |
|---|---|
| Color — primary | Upload CTA fill, active progress bar |
| Color — danger | Delete dialog confirm, error progress, cancel emphasis |
| Color — success | upload/download completion check |
| Color — surface/text | file tiles, detail sheet rows (surface-1 / text-primary-secondary) |
| Color — info | "Large file" warnings |
| Type | file name = title-medium; metadata line = body-small + text-secondary |
| Spacing | list rows space-16; sheet padding space-24; card gaps space-8 |
| Radius | sheets/cards use radius-lg; progress card radius-md |
| Elevation | bottom sheets per tokens; cards flat in list |
| Motion | m-fast / m-base as per 10_Interaction_Specification.md |
| Icons | FileTypeIcon uses the token icon set + mime map |
2. Component → system mapping
| Files component | System basis (00-shared/03) | Files-specific extension |
|---|---|---|
| FileTile | AppListTile | + FileTypeIcon, size/date subline, conditional menu items |
| UploadSheet | AppBottomSheet + AppButton | + picker rows, preview card, validation |
| UploadProgressCard | AppProgress + AppButton(text) | + bytes counter, cancel |
| DownloadManagerSheet | AppBottomSheet + AppProgress | + per-task list (queued/active/done) |
| DeleteConfirmDialog | AppDialog destructive | + file-name interpolated copy |
| EmptyFilesState | AppEmptyState | + attach CTA (conditional) |
| Permission state | AppEmptyState | + lock variant |
| Offline | AppOfflineBanner | global, no module variant |
3. Semantics by state (files-specific)
| State | Color | Icon | Copy pattern |
|---|---|---|---|
| uploading | primary | progress | "Uploading name — 40%" |
| success | success | check | "Uploaded name" / "Downloaded name" |
| error | danger | alert | "Upload failed — retry" |
| empty | neutral | file-outline | "No files yet" |
| locked | neutral dim | lock | "Ask an admin for file access" |
4. Density & adaptive
- Phones: full-width rows, bottom sheets.
- Tablet/desktop: list + detail master-detail; sheets become centered dialogs; hover highlight on rows; larger hit areas for menu.
- Landscape: sheet becomes dialog (per 05_Screen_Inventory.md keyboard/landscape note).
5. a11y mapping (baseline 00-shared/09)
- Progress announced via live region (upload + download).
- File rows: single combined semantics; menu actions exposed.
- Contrast: danger on surface meets AA; progress text ≥
body-smallAA.
12 — API Mapping (Files Module)
- 1. Endpoint table
- 2. Detail
- 3. Permission coverage
- 4. Planned / forward-looking endpoints
- 5. Client mapping
- 6. Envelope & errors
Exact backend contract. Conventions (envelope, errors, versioning): 00-shared/07_API_Conventions. Source:
files.controller.ts:29-71,files.service.ts,file.schema.ts,src/modules/rbac/permissions.constants.ts:86-88.
1. Endpoint table
| # | Method | Path | Permission | Source | Purpose |
|---|---|---|---|---|---|
| 1 | POST | /api/v1/files/upload | file.upload | files.controller.ts:29-41 | Upload single file (multipart) |
| 2 | GET | /api/v1/files | file.read | files.controller.ts:43-47 | List tenant files, createdAt desc |
| 3 | GET | /api/v1/files/:id | file.read | files.controller.ts:49-53 | File metadata; 404 if missing |
| 4 | GET | /api/v1/files/:id/download | file.read | files.controller.ts:55-64 | Download raw bytes (attachment) |
| 5 | DELETE | /api/v1/files/:id | file.delete | files.controller.ts:66-71 | Storage delete + metadata soft-delete |
2. Detail
2.1 POST /api/v1/files/upload — 201
- Request:
multipart/form-data, partfile(binary) (files.controller.ts:31-37). Client-supplied:originalname,mimetype,size(used verbatim,files.service.ts:32-33). - Flow: tenant from
TenantContextService.requireTenantId()(files.service.ts:28) →storage.upload({buffer, filename: <uuid>--<originalName>, mimeType, tenantId})(files.service.ts:29-34) → record create (files.service.ts:36-45). - Response body:
FileRecord—{id, originalName, mimeType, size, storageProvider, storageFileId, storagePath, createdAt, updatedAt}.storageFileId/storagePathare internal; do not expose in UI. - Errors: 400 (malformed multipart), 401/403 (guards
(planned)), 500 (provider).
2.2 GET /api/v1/files — 200
- Query: no params (no pagination/filter; gap). Sorted
createdAt: -1(files.service.ts:48-50). Tenant-scoped + soft-delete filtered viaBaseRepository(file.repository.ts:9-15).
2.3 GET /api/v1/files/:id — 200 / 404
- 404
File not found(files.service.ts:54) for unknown or soft-deleted ids.
2.4 GET /api/v1/files/:id/download — 200
- Headers:
Content-Type: <mimeType>(files.controller.ts:60);Content-Disposition: attachment; filename="<originalName>"(files.controller.ts:61). - Body: full bytes (
res.send,files.controller.ts:63) — proxied buffer, no redirect, no signed URL, no Range/streaming today. - Flow:
findById→storage.download(storageFileId)(files.service.ts:58-64).
2.5 DELETE /api/v1/files/:id — 200
- Response:
{ message: 'File deleted' }(files.controller.ts:70). - Flow:
findById(404 if gone) →storage.delete(storageFileId)→repo.softDelete(id)(files.service.ts:66-70). Object hard-deleted; metadata retained (soft).
3. Permission coverage
| Permission | Endpoints | Note |
|---|---|---|
file.read | list, get, download | permissions.constants.ts:86 |
file.upload | upload | permissions.constants.ts:87 |
file.delete | delete | permissions.constants.ts:88 |
No file.update/file.share — matching UI (no replace, no share UI).
4. Planned / forward-looking endpoints
| Endpoint | Status | Source |
|---|---|---|
GET /files/:id/signed-url?expiresInSeconds= (TTL) | (planned) — provider method exists (storage-provider.ts:25), service never calls it; blueprint TTL STORAGE_ARCHITECTURE.md:44,66 | r2.provider.ts:77-86 |
Upload limits (multer limits.fileSize, MIME allowlist) | (planned) — nothing enforces today | — |
Pagination ?page&limit, filters | (planned) / (proposed) | — |
| Multi-delete, restore | (planned) | — |
| Streaming / Range for large downloads | (planned) | STORAGE_ARCHITECTURE.md:67 |
| Retention/cleanup jobs (3-day tier) | (planned) | IMPLEMENTATION_PLAN.md:176 |
5. Client mapping
| Screen/Cubit | Endpoint(s) |
|---|---|
| FilesCubit (list) | GET /files |
| UploadCubit | POST /files/upload |
| Detail sheet | GET /files/:id |
| DownloadCubit | GET /files/:id/download |
| Delete flow | DELETE /files/:id |
6. Envelope & errors
- Success/failure envelopes per 00-shared/07_API_Conventions and
common/interceptors/response-envelope.interceptor.ts/common/filters/http-exception.filter.ts. - HTTP statuses above are what the client must handle; 401/403 require guards which are
(planned)(AGENTS.md: auth not yet implemented).
13 — State Management (Files Module)
Flutter-side state model for file flows. Global architecture, Cubit patterns, storage strategy: 00-shared/06_State_Management, 00-shared/11_Flutter_App_Architecture. This file defines the module's Cubits and the upload state machine.
1. Cubits
1.1 FilesCubit — list
sealed class FilesState {}
class FilesLoading extends FilesState {}
class FilesLoaded extends FilesState {
final List<FileRecord> files; // newest first (server order, files.service.ts:48-50)
final Map<String, List<FileRecord>> byContext; // client-side linkage index
}
class FilesError extends FilesState { final String message; }
- Load:
GET /api/v1/files(full tenant list; no pagination — gap). - Context filter: module passes context key;
byContextderived client-side (server has no entity linkage field,file.schema.ts:9-25). - Events:
load(),refresh(),upsertLocal(record),removeLocal(id).
1.2 UploadCubit — upload
sealed class UploadState {}
class UploadIdle extends UploadState {} // sheet closed / untouched
class UploadPicking extends UploadState {} // system picker open
class UploadSelected extends UploadState { final PickedFile f; }
class UploadUploading extends UploadState {
final PickedFile f; final double progress; final int sentBytes; final int totalBytes;
}
class UploadSuccess extends UploadState { final FileRecord record; }
class UploadFailed extends UploadState { final PickedFile f; final String message; }
- Upload flow:
POST /api/v1/files/uploadwithonSendProgress(dio) drivingprogress(see15_Flutter_Implementation_Guide.md). - Success →
FilesCubit.upsertLocal(record); failure → retry re-POSTs (new record — no server idempotency).
1.3 DownloadCubit — download manager
class DownloadTask {
final String id; final String fileId; final String name;
final DownloadStatus status; // queued | downloading | completed | failed | cancelled
final double progress; final String? localPath; final int? bytesReceived;
}
- One queue, multiple tasks (list of
DownloadTaskin state). - Cancel: abort dio request; no resume (no server Range) — retry restarts from 0.
- Completed:
localPathfrompath_providertemp dir; "Open"/"Share"(forward-looking).
1.4 FileDetailCubit — detail sheet
- Loads
GET /api/v1/files/:id; 404 → emitFileGone→ sheet closes, row removed.
2. Upload state machine
stateDiagram-v2
[*] --> Idle
Idle --> Picking : open sheet
Picking --> Selected : file picked
Picking --> Idle : cancelled
Selected --> Uploading : POST /files/upload (multipart)
Selected --> Picking : re-pick
Uploading --> UploadSuccess : 201 FileRecord
Uploading --> UploadFailed : 4xx/5xx/network
Uploading --> Idle : user cancels (abort transport)
UploadFailed --> Uploading : retry (new POST)
UploadSuccess --> [*] : tile inserted in list
Key invariants:
- Cancelled uploads are never recorded server-side (body discarded at transport; no
partial-cleanup job yet —
STORAGE_ARCHITECTURE.md:66(planned)). - Retry after failure creates a new
FileRecord(no idempotency key) — duplicate possibility must be surfaced as "Retry again?" not "Resume".
3. Download state machine
stateDiagram-v2
[*] --> Queued : user taps download
Queued --> Downloading : GET /files/:id/download
Downloading --> Completed : bytes → local file
Downloading --> Failed : error/network
Downloading --> Cancelled : user cancels
Failed --> Downloading : retry (restart, no resume)
Completed --> [*] : Open / Share (forward-looking)
4. Persistence
byContextlinkage index: cached in app prefs (sharedLocalStore)(proposed)— rebuilt fromGET /fileson load; never treated as source of truth.- Upload outbox for offline retry:
(forward-looking). - No server-side state beyond the record itself (stateless module).
5. Cross-cutting
- 401/403 → global auth event (guards
(planned)). - Tenant switching (multi-institution, Phase 6
(planned)) → full Cubit reset + reload.
14 — QA Checklist (Files Module)
- 1. Functional
- 2. Large files & memory
- 3. Partial uploads & cancellation
- 4. MIME spoofing
- 5. Expired URLs / signed URLs
(planned) - 6. Tenant isolation
- 7. RBAC
(planned) - 8. Errors & resilience
- 9. Performance
Files-specific verification. Global QA baseline: 00-shared/10_QA_Baseline. Every check below is traceable to a source line; focus areas mandated by the module's real behavior: large files, partial uploads, MIME spoofing, expired URLs, tenant isolation.
1. Functional
-
Upload a file → 201 record with
originalName,mimeType,sizematching the multipart part (files.service.ts:32-33). -
List returns newest-first (
createdAt desc,files.service.ts:48-50). -
GET /files/:idreturns 404 for unknown and soft-deleted ids (files.service.ts:54, repository soft-delete filter). -
Download sets
Content-Type= stored mimeType andContent-Disposition: attachment; filename="<originalName>"(files.controller.ts:60-61). -
Delete: object removed from provider AND metadata soft-deleted; list no longer shows
it;
GET /files/:id→ 404 (files.service.ts:66-70). -
Uploading a file whose name contains
"or;— header quoting stays valid (files.controller.ts:61); names are un-sanitized (files.service.ts:31) — verify no header injection.
2. Large files & memory
-
No size limit is enforced (gap): verify behavior at 1 MB, 25 MB, 100 MB.
Confirm the API process memory does not balloon per concurrent request (buffered path:
FileInterceptorbuffer →storage.upload(buffer)→res.send(buffer),files.controller.ts:39-63). Record the practical ceiling. - Concurrent uploads (5 × 25 MB) — no OOM, no socket timeout.
- Large download on slow link — client shows progress; no server-side timeout crash.
-
Streaming/Range: confirm absent (no
Accept-Ranges) — document that resume is impossible; UI must not offer pause/resume.
3. Partial uploads & cancellation
- Cancel mid-upload → no orphan object at provider? (Today: unknown — no cleanup job; partial bodies are discarded by the server, but verify no zombie object when the provider received a complete object before the client disconnected.)
- Retry after failure → new record created (no idempotency) — confirm no duplicate storage objects remain.
4. MIME spoofing
-
Upload
evil.pdf(actually a script,mimetype: application/pdf) → download servesContent-Type: application/pdf(echoed,files.controller.ts:60) — no server sniffing. Document: files render/execute per their claimed type; client "open with caution" guidance applies (09_User_Behaviour.md §6). -
mimetype: application/octet-streamround-trips unchanged.
5. Expired URLs / signed URLs (planned)
-
R2
getSignedUrl(fileId, expiresIn)respects TTL (r2.provider.ts:77-86) — after expiry, request returns 403; client must re-request a fresh URL (no raw 403 UX). -
Appwrite
getSignedUrlignoresexpiresInSeconds(appwrite-storage.provider.ts:67-74) — flagged gap; verify expected TTL behavior before relying on it. -
Local driver returns fake path
/api/v1/files/<id>(local-storage.provider.ts:47-51, ponytail shortcut) — dev-only; never ship.
6. Tenant isolation
-
Tenant A upload → Tenant B:
GET /filesdoes not include it;GET /files/:idwith A's id → 404 (repository tenant scoping,file.repository.ts:9-15+TenantContextService,files.service.ts:28). -
Storage keys: R2
<tenantId>/<uuid>--<name>(r2.provider.ts:39); localstorage/<tenantId>/...(local-storage.provider.ts:24). Verify object keys carry tenant prefix. Appwrite: single bucket, no prefix (appwrite-storage.provider.ts:38-44) — isolation relies on metadata only; verify cross-tenant download by object id is impossible via API paths. - Delete from one tenant never touches another tenant's objects.
7. RBAC (planned)
-
file.read/file.upload/file.deletematrix on all 5 routes (permissions.constants.ts:86-88); 403 without permission; guards are not yet implemented (AGENTS.md) — test once landed.
8. Errors & resilience
-
Provider down (
healthCheckthrows,storage-provider.ts:27) → upload/download/delete fail with 500 and clear message; no partial Mongo state on upload failure (files.service.ts:29-34— record created only after successful provider upload). -
Delete when provider object already gone →
storage.deletethrows; record remains soft-deletable — decide reconciliation behavior(proposed). - 404 paths (list, get, download, delete) return the standard error envelope (00-shared/07_API_Conventions).
9. Performance
-
List with 1 000 records — envelope + JSON serialization time; no pagination exists
(gap;
(planned)). -
thumbnailFileId,width,heightfields never populated (file.schema.ts:31-43) — verify no client code reads them.
15 — Flutter Implementation Guide (Files Module)
- 1. Upload with progress (dio)
- 2. List
- 3. Download with progress
- 4. Delete
- 5. Permissions & guards
(planned) - 6. Offline & outbox
(forward-looking) - 7. Tests (per
00-shared/10_QA_Baseline+ module QA14_QA_Checklist.md)
How to build the Files module on the existing app architecture (00-shared/11_Flutter_App_Architecture, 00-shared/06_State_Management). Backend contract is fixed by 12_API_Mapping.md; state model by 13_State_Management.md. Deps:
dio(transport, already core),path_provider,file_picker(or platform pickers),open_filex/share(forward-looking).
1. Upload with progress (dio)
Future<FileRecord> upload(PickedFile f, {void Function(int, int)? onProgress}) async {
final form = FormData.fromMap({
'file': await MultipartFile.fromFile(
f.path,
filename: f.name,
contentType: DioMediaType.parse(f.mimeType), // echo client type — server trusts it
),
});
final res = await api.post('/files/upload',
data: form,
onSendProgress: onProgress,
options: Options(contentType: 'multipart/form-data'),
);
return FileRecord.fromJson(res.data['data']); // envelope per 00-shared/07
}
- Server contract: multipart part named
file(files.controller.ts:31), one file per request; field name is fixed —MultipartFile.fromFilemust be wrapped in a map with keyfile. - Progress: dio
onSendProgressgives(sent, total). Until the first event (server buffering), show indeterminateAppProgress(10_Interaction_Specification.md §3). - Cancel:
CancelTokenper upload; abort discards the body client-side (no server partial state). - Retry: re-POST the whole body; expect a new
FileRecord(no idempotency). - Auth: attach Bearer token via the shared dio interceptor; guards are
(planned), so test with a role that holdsfile.upload(permissions.constants.ts:87).
UploadCubit wiring
UploadIdle → UploadPicking → UploadSelected → UploadUploading(progress) → UploadSuccess | UploadFailed | cancelled (mermaid in 13_State_Management.md). On success call
FilesCubit.upsertLocal(record); on failure keep the picked file for Retry.
2. List
final res = await api.get('/files'); // full tenant list, createdAt desc
final files = (res.data['data'] as List).map(FileRecord.fromJson).toList();
- No pagination/filter params (
files.service.ts:48-50) — load once, group client-side by context key inbyContext(see13_State_Management.md).
3. Download with progress
Future<String> download(FileRecord f, {void Function(int, int)? onProgress, CancelToken? cancel}) async {
final dir = await getTemporaryDirectory();
final path = '${dir.path}/${sanitizeForFs(f.originalName)}';
await api.download('/files/${f.id}/download', path,
onReceiveProgress: onProgress, cancelToken: cancel);
return path;
}
- Server: buffered proxied bytes with
Content-Disposition: attachment(files.controller.ts:55-64) — dio downloads the body to disk; no redirect handling needed. - Cancel:
CancelToken; no resume (noRange) — retry restarts from 0. - Open:
open_filex/url_launcherfor the temp path; Share viashare_plus(forward-looking). - Filename: the server returns the original name only in the record + header; sanitize
locally for filesystem safety (server does not sanitize,
files.service.ts:31).
DownloadManagerSheet
Single DownloadCubit owning a task list (13_State_Management.md §1.3); sheet lists
queued/active/completed tasks with per-task AppProgress and cancel.
4. Delete
await api.delete('/files/${f.id}'); // { message: 'File deleted' }
filesCubit.removeLocal(f.id); // optimistic, rollback on error
- 404 → treat as already gone, remove row (
files.service.ts:54). - Only render delete when the role holds
file.delete(permissions.constants.ts:88).
5. Permissions & guards (planned)
- Route decorators exist (
files.controller.ts:30,44,50,56,67); RBAC guards not yet implemented. Client: read permission flags from the roles endpoint and hide/show Attach/Delete/Download accordingly; handle 403 uniformly once guards land.
6. Offline & outbox (forward-looking)
- Upload/download actions disabled while offline (
AppOfflineBanner); queue uploads in a local outbox and drain on reconnect.
7. Tests (per 00-shared/10_QA_Baseline + module QA 14_QA_Checklist.md)
- Unit:
UploadCubitstate machine (cancel, retry, success path);byContextgrouping. - Widget:
FileTilelabels; progress card percent rendering; delete dialog copy. - Integration (mock dio): multipart body contains part
file; progress callback invoked; envelope unwrap; 404 handling. - E2E: upload → appears in list → download → file matches bytes → delete → gone.
01 - Product Overview (Health Module)
StudyLyon - multi-tenant ERP / School Management API. This package designs the Health module client (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, indicator keys, wire contracts, rate limits and auth posture are derived directly from
src/modules/health/**,src/app/app.module.ts,src/common/guards/rate-limit.constants.ts,src/common/interceptors/response-envelope.interceptor.ts,src/common/filters/http-exception.filter.ts,src/modules/rbac/permissions.constants.tsanddocs/IMPLEMENTATION_PLAN.md. No feature is invented - anything not present in source is flagged(planned)/(proposed)/(forward-looking).
Heads-up: per the PRD, the mobile client is out of Phase 1 scope
(PRODUCT_REQUIREMENTS_DOCUMENT.md:144 - "Native mobile apps (web-first)");
this package is the forward-looking spec the client will be built against later.
The health endpoint itself is a machine-to-machine surface first: it exists for
load balancers, orchestrators and uptime probes, and only secondarily for humans.
1. Purpose
The health module exposes a single Terminus-powered check endpoint that reports the reachability of the API's runtime dependencies in one round-trip:
GET /api/v1/health- combined check of four indicators (health.controller.ts:27-37): MongoDB ping, Redis PING, storage provider reachability probe, and BullMQ queue accessibility with pending-job counts.- Status model - Terminus
HealthCheckResultshape:status(ok|error),info,error,details; individual dependencies reportup/down(health.controller.spec.ts:21-36). - HTTP semantics - 200 when every dependency is up; 503
(ServiceUnavailableException raised by
@HealthCheck) when any is down. - Public by design -
@Public()bypasses the JWT/RBAC global guards (health.controller.ts:15,app.module.ts:129-131); rate-limited to 30 req/min on thepublictier (health.controller.ts:16,rate-limit.constants.ts:4,IMPLEMENTATION_PLAN.md:48).
| Responsibility | Source |
|---|---|
| Terminus registration | health.module.ts:8-17 (imports TerminusModule, declares 3 custom indicators) |
| Combined check endpoint | health.controller.ts:27-37 |
| MongoDB indicator | Terminus MongooseHealthIndicator.pingCheck('mongodb'), health.controller.ts:32 |
| Redis indicator (PING) | redis-health.indicator.ts:12-23 |
| Storage indicator (provider probe) | storage-health.indicator.ts:16-26, storage-provider.ts:21-27 |
| BullMQ indicator (4 queues, job counts) | bullmq-health.indicator.ts:19-37, queue.constants.ts:2,5,14, dlq.constants.ts:1 |
| Module wiring | app.module.ts:87 |
| Public tier rate limit | rate-limit.constants.ts:4, IMPLEMENTATION_PLAN.md:48 |
2. Business goals
| Goal | Measure |
|---|---|
| One-probe dependency visibility | all 4 indicators answered in a single request (health.controller.ts:31-36) |
| Orchestrator-ready signals | 200 on status: 'ok', 503 on any down (Terminus @HealthCheck behavior) |
| No auth overhead for probes | @Public() bypass (health.controller.ts:15); load balancers need no tokens |
| Abuse containment | 30 req/min sliding-window, Redis-backed (rate-limit.constants.ts:4) |
| Partial-failure detail | error + details keys distinguish which dependency failed while others stay up (health.controller.spec.ts:29-35) |
3. User goals
- Platform / DevOps engineer: one curl-able endpoint that says whether the API, DB, Redis, object storage and background queues are reachable, and which one is not when something is.
- Support engineer: a fast "is it us or is it them?" signal before opening tickets.
- School admin (forward-looking): a visual "App Health" screen in the admin
surface that mirrors the endpoint as status cards, with the option to re-check
manually -
(proposed)throughout this package.
4. Scope
4.1 In scope (implemented backend)
Single combined endpoint GET /api/v1/health (and /api/v2/health, both
versions registered - health.controller.ts:28) with four indicators:
mongodb, redis, storage, bullmq (health.controller.ts:31-36). Terminus
HealthCheckResult response envelope (200/503). @Public() + public rate
tier (30 req/min). Custom indicators carry extra payload: Redis reports its
ping value (redis-health.indicator.ts:16), BullMQ reports per-queue
pendingJobs counts (bullmq-health.indicator.ts:30).
4.2 Planned (not in source)
- Liveness/readiness split -
GET /api/v1/health/readyandGET /api/v1/health/liveappear indocs/user-flows/END_TO_END_USER_FLOWS.md:738-740but no such routes exist inhealth.controller.tstoday; the single combined check covers both concerns - marked(planned)throughout. - Disk and memory indicators - Terminus ships
DiskHealthIndicator/MemoryHealthIndicatorbut neither is wired; onlymongodb,redis,storage,bullmqare checked (health.controller.ts:31-36). - Uptime / version fields - the response carries no uptime, process
version, or build hash;
HealthCheckResultis returned verbatim (health.controller.ts:30-37).
4.3 Forward-looking (client roadmap)
"App Health" admin screen (status cards for API, DB, Redis, Queues, Storage;
last-check time; manual retry) - (proposed) in 05/06. Per PRD the native
client itself is post-Phase 1 (PRODUCT_REQUIREMENTS_DOCUMENT.md:144).
4.4 Proposed (analytics)
Analytics events on screens (health.*.*) per 00-shared/10 §8 - (proposed).
5. Non-goals (this version)
- Authenticated health variants - no
health.*permission exists inpermissions.constants.ts:1-97(verified: zero matches for "health"); the endpoint is public and rate-limited only. A guarded admin variant is(proposed)for the future screen. - Per-tenant health - no
tenantIdscoping; health is a global infrastructure concern (AGENTS.mdtenancy conventions do not apply). - Diagnostics depth - no latency metrics, no per-queue detail beyond
summed
pendingJobs(bullmq-health.indicator.ts:27-30), no DB query timing. - Historical availability - endpoint is stateless; no uptime history, no incident log, no alerting (alerting out of scope of this module).
- Load-shedding - the check itself has no timeout budget; a hanging
dependency hangs the probe until the global
RequestTimeoutMiddleware(app.module.ts:141) or client-side timeout fires.
02 - User Personas (Health Module)
Who consumes the health surface. The endpoint itself is machine-to-machine; human personas act on its output. Screens referenced are
(proposed)unless noted. Source anchors:health.controller.ts:14-37,redis-health.indicator.ts:12-23,storage-health.indicator.ts:16-26,bullmq-health.indicator.ts:19-37.
1. Primary (machine consumers)
1.1 The Load Balancer
- Job: forward traffic only to a healthy instance.
- Consumes:
GET /api/v1/healthon a fixed interval. - Contract: 200 = healthy, 503 = drain (
health.controller.ts:27-37, Terminus@HealthCheckthrowsServiceUnavailableExceptionon any down). - Needs: no auth (
@Public(),health.controller.ts:15), no session, no tenant headers, stable response shape. - Frustrations avoided: 30 req/min cap is ample for typical 5-30 s probe
intervals (
rate-limit.constants.ts:4).
1.2 The Orchestrator / CI Gate
- Job: block deploys when dependencies are down; gate rollouts on DB/Redis.
- Consumes: the same endpoint, plus parseable
info/errorkeys to know which dependency failed (health.controller.spec.ts:21-36).
2. Secondary (human consumers)
2.1 DevOps / Platform Engineer (primary human)
- Job: incident triage - "is the API up or is one dependency down?"
- Workflow:
curl /api/v1/health→ readstatus; onerror, readerror.<key>.status === 'down'to name the culprit (e.g.redis), then open the storage/queue/DB consoles. - Likes: one request answers everything; Redis reports its
pingvalue (redis-health.indicator.ts:16); BullMQ reportspendingJobsper queue (bullmq-health.indicator.ts:30) as a backlog smell. - Gaps felt: no uptime/version field (can't tell "which build is up" from the payload); no disk/memory indicator despite Terminus support.
2.2 Support Engineer
- Job: first-response triage for tenant reports ("app is down").
- Flow: checks health screen
(proposed); if allup, escalates with tenant context; if a dependency isdown, routes to the owning team. - Needs: human-readable screen, not curl - the "App Health" screen in
05/06
(proposed).
2.3 School Admin (forward-looking, light)
- Job: confirm "StudyLyon is having an issue" during an outage rather than fielding parent calls.
- Needs: the same status cards without technical jargon; last-check time;
retry button; no ability to change anything - read-only surface,
(proposed).
3. Non-persona
- Tenant users (students/parents/teachers): never see this surface; health
is an operations concern. Per PRD, native mobile is out of Phase 1
(
PRODUCT_REQUIREMENTS_DOCUMENT.md:144).
4. Access summary
| Persona | Surface | Auth | Source |
|---|---|---|---|
| LB / orchestrator | GET /api/v1/health | none (public) + rate 30/min | health.controller.ts:15-16 |
| DevOps | endpoint + (proposed) screen | none for endpoint | rate-limit.constants.ts:4 |
| Support | (proposed) screen | (proposed) - no health.* permission exists today | permissions.constants.ts:1-97 |
| Admin | (proposed) screen | (proposed) | - |
03 - User Journey (Health Module)
- 1. Machine journey - LB health probe (implemented today)
- 2. DevOps incident journey (human)
- 3. Support engineer journey (screen
(proposed)) - 4. School admin journey (forward-looking)
- 5. Key moments of truth
End-to-end journeys around the health surface. Machine journey reflects the implemented backend exactly; human journeys rely on the
(proposed)App Health screen (05/06). Sources:health.controller.ts:14-37,response-envelope.interceptor.ts:47-60,http-exception.filter.ts:73-81.
1. Machine journey - LB health probe (implemented today)
LB ── GET /api/v1/health (30s interval) ──► API
API runs 4 checks in parallel (health.controller.ts:31-36):
mongodb Terminus pingCheck (health.controller.ts:32)
redis PING → 'PONG' (redis-health.indicator.ts:12-23)
storage provider healthCheck() (storage-health.indicator.ts:16-26)
bullmq getJobCounts on 4 queues(bullmq-health.indicator.ts:19-37)
│
├─ all up ──► 200 {success, message:'OK', data:{status:'ok', info:{…},
│ error:{}, details:{…}}, timestamp, requestId}
│ (response-envelope.interceptor.ts:47-60)
└─ any down ─► 503 {success:false, message, error:{code:'INTERNAL_SERVER_ERROR'},
timestamp, requestId}
(http-exception.filter.ts:56-58, 73-81)
LB: 200 → route traffic; 503 → drain instance
- One instance, one request, four dependency answers. No tenant context, no
auth - the probe is public (
health.controller.ts:15). - Partial failure:
status: 'error',infostill lists the healthy deps,error.<key>.status === 'down'names the culprit (health.controller.spec.ts:21-36).
2. DevOps incident journey (human)
- Detect - probe alert, or user-reported slowness.
- Triage -
curl /api/v1/health; readdata.status.ok→ API and its four dependencies are reachable; problem is upstream (network, DNS, tenant-specific), not the platform.error→ readdata.errorfor the down key (mongodb|redis|storage|bullmq).
- Diagnose - down key names the console to open:
redis→ check thepingdetail was lost on failure (redis-health.indicator.ts:17-22throws without payload).bullmq→ pending-job counts were the last healthy signal (bullmq-health.indicator.ts:30); a growingpendingJobssmells like a stalled worker before a hard down.
- Resolve - fix the dependency; next probe flips the instance back to 200; LB re-routes automatically.
- Confirm - re-curl;
infolists all fourup(health.controller.spec.ts:23-28).
3. Support engineer journey (screen (proposed))
- Open App Health screen; auto-poll runs (
13). - Cards: API (implicit 200), DB, Redis, Queues, Storage
(
05S1). A red card names the culprit - no curl needed. - Tap Retry for an immediate re-check instead of waiting for the next
poll tick (
06S1). - Screenshot the card states + last-check time into the ticket.
4. School admin journey (forward-looking)
- Parent reports "app is down" → admin opens App Health.
- Green cards → "working here; issue may be device/network" → generic status communication.
- Red cards → confirm platform issue → relay to support with the card screenshot.
- Admin cannot fix anything from this screen - read-only
(proposed).
5. Key moments of truth
| Moment | Source truth |
|---|---|
| Endpoint answers fast, all up | 200 + data.status === 'ok' |
| One dependency down | 503 + data.error names it; others remain up in info |
| All dependencies down | 503; info empty/absent, error lists all keys |
| Rate-limit pressure | 429 (sliding window; probes on 5s intervals risk it - keep ≥ 10s) |
04 - Information Architecture (Health Module)
- 1. Backend IA (implemented)
- 2. Screen IA (proposed)
- 3. Information priorities
- 4. Explicitly absent (keep it lean)
Where the health surface lives in the StudyLyon IA. Global conventions in 00-shared/05. The backend surface is a single public endpoint; the screen IA below is
(proposed)for a future admin surface.
1. Backend IA (implemented)
/api/v1/health GET combined Terminus check (health.controller.ts:27-37)
├─ mongodb (health.controller.ts:32)
├─ redis (redis-health.indicator.ts:12-23)
├─ storage (storage-health.indicator.ts:16-26)
└─ bullmq (bullmq-health.indicator.ts:19-37)
- Root:
/apiglobal prefix, URI versioning, default version1(env.ts:14,16,main.ts:44-48); health also accepts/api/v2/health(@Version(['1','2']),health.controller.ts:28). - Global, not tenant-scoped: no
tenantId, no tenant middleware context - a platform-wide concern. It does not appear in any tenant menu tree. - Planned siblings:
/api/v1/health/liveand/api/v1/health/ready(liveness/readiness split) exist only indocs/user-flows/END_TO_END_USER_FLOWS.md:738-740-(planned), not routed.
2. Screen IA (proposed)
App Health lives in the admin/operations zone, sibling to Audit
(audit.read surface) and Scheduler, not under any tenant feature tree:
Settings / Operations (admin) (proposed placement)
└── App Health S1 - status dashboard
├── Service status cards API / DB / Redis / Queues / Storage
├── Last-check time
└── Retry (manual re-check)
| Node | Content | State source |
|---|---|---|
| API card | implicit - the request itself succeeded | HTTP 200 of the call |
| DB card | data.info.mongodb.status (health.controller.ts:32) | up/down |
| Redis card | data.info.redis.status + ping (redis-health.indicator.ts:16) | up/down |
| Storage card | data.info.storage.status (storage-health.indicator.ts:19) | up/down |
| Queues card | data.info.bullmq.status + pendingJobs (bullmq-health.indicator.ts:30) | up/down |
3. Information priorities
- Overall state - one glance: all green / one red.
- Culprit identification - which dependency, from
data.error(health.controller.spec.ts:29-35). - Freshness - last-check time (client-side timestamp of the 200/503).
- Detail -
pendingJobsbacklog on the Queues card; hidden by default.
4. Explicitly absent (keep it lean)
- No per-tenant health hierarchy (non-goal,
01§5). - No uptime history / incident timeline -
(planned)at best, stateless endpoint today. - No write actions beyond Retry - no config, no thresholds, no toggles on the
screen (
(proposed)surface is read-only + retry).
05 - Screen Inventory (Health Module)
- Legend
- 1. App Health Screen (
/ops/health)(proposed) - 2. Incident Detail Sheet (per failed card)
(proposed) - 3. (No other screens)
- Shared components used
- Analytics events (proposed)
- Keyboard, landscape, tablet, desktop
Every screen of the Health module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics. The human-facing screens are
(proposed)- the implemented backend surface is a single public endpoint with no UI (health.controller.ts:27-37).
Legend
States = idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission.
Analytics events follow {module}.{screen}.{action} (proposed; SDK open - 00-shared/10 §8).
1. App Health Screen (/ops/health) (proposed)
| Field | Detail |
|---|---|
| Purpose | Ops overview: are API, DB, Redis, Queues, Storage reachable right now? |
| Entry | ops menu, support hot-link, incident deep link /ops/health |
| Exit | back to ops zone; no downstream screens (read-only surface) |
| Data source | GET /api/v1/health - data.info + data.error (health.controller.ts:27-37, health.controller.spec.ts:21-36) |
| Composition | AppBar "App Health" → overall status banner → 5 status cards (API, DB, Redis, Queues, Storage) → last-check time + retry button |
| Widgets | AppCard, AppBadge, AppSkeleton, AppEmptyState, AppOfflineBanner, AppButton (00-shared/03); module widgets StatusCard, StatusDot, PendingJobsTile (07) |
| States | loading (skeletons); success all-up; success partial (red culprit cards); error (network/timeout - offline banner + retry); rate-limited (429 countdown); disabled (poll paused when app backgrounded) |
| Polling | auto-poll every 30 s while foregrounded (13 §2); manual retry always available |
| Analytics | health.screen.open, health.screen.poll_success, health.screen.poll_failure(503), health.retry.tap |
| Adaptive | phone single column; tablet/desktop 2-column card grid; landscape scroll |
| a11y | per-card semantics label "DB: up"; overall banner as live region on state change (00-shared/09) |
| Motion | card flash on state change (m-fast); banner collapse animation (00-shared/08) |
2. Incident Detail Sheet (per failed card) (proposed)
| Field | Detail |
|---|---|
| Purpose | Show what the endpoint knew about the failure - no extra API call |
| Entry | tap a red card |
| Content | failed key (mongodb/redis/storage/bullmq), data.error.<key> payload, Queues card shows pendingJobs (bullmq-health.indicator.ts:30), last-check time |
| Exit | dismiss sheet |
| States | static - renders the already-fetched payload; no network |
| a11y | sheet focus-trapped; content readable by screen reader |
| Note | For redis failures the payload carries no detail (indicator throws bare on failure, redis-health.indicator.ts:17-22) - show "no additional detail reported" |
3. (No other screens)
- No forms, no list screens, no settings screen. The module is intentionally
one screen + one detail sheet
(proposed). - Liveness/readiness screens do not exist; the split endpoints are
(planned)(docs/user-flows/END_TO_END_USER_FLOWS.md:738-740), and even then they are probe endpoints, not screens.
Shared components used
AppCard, AppButton, AppBadge, AppSkeleton, AppEmptyState,
AppOfflineBanner, AppSnackbar, AppBanner. Module-specific: StatusCard,
StatusDot, PendingJobsTile, LastCheckedLabel - defined in
07_Component_Library.md.
Analytics events (proposed)
health.screen.{open,poll_success,poll_failure}, health.retry.tap,
health.card.detail_open (all proposed).
Keyboard, landscape, tablet, desktop
- Scrollable single column on phone; 2-column grid ≥ 700 dp (00-shared/11 responsive rules).
- No text input anywhere - no keyboard handling needed.
- Retry button reachable via focus traversal; cards are buttons for screen readers.
06 - Screen Specifications (Health Module)
Detailed specifications for each screen. All screens
(proposed)- the implemented backend is endpoint-only (health.controller.ts:27-37). Wire data references 12_API_Mapping.md; state references 13_State_Management.md.
S1. App Health Screen (/ops/health) (proposed)
Purpose
Operational at-a-glance availability of the API and its four dependencies, mirroring the combined Terminus check. Read-only; the only action is Retry.
Layout (phone)
┌──────────────────────────────────────────────┐
│ ‹ App Health [⚙] │ AppBar
├──────────────────────────────────────────────┤
│ ● All systems operational │ OverallBanner (live region)
│ (or) ● 1 of 5 degraded — Redis is down │
├──────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ │
│ │ API ● up │ │ DB ● up │ │ StatusCard grid
│ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Redis ● up │ │ Storage ● up │ │
│ │ ping: PONG │ └──────────────┘ │
│ └──────────────┘ ┌──────────────┐ │
│ │ Queues ● up │ │
│ │ pending: 12 │ │ PendingJobsTile
│ └──────────────┘ │
│ Last checked 12:04:31 · Retry │ LastCheckedLabel + Retry
└──────────────────────────────────────────────┘
Behavior
| # | Event | Result |
|---|---|---|
| 1 | Enter screen | HealthCubit.fetch() immediately (13 §2); 5 skeleton cards |
| 2 | 200, all up | banner "All systems operational"; cards green; last-check = now |
| 3 | 503, partial | banner "1 of 5 degraded — Redis is down"; red card + detail sheet affordance; healthy cards stay green (health.controller.spec.ts:29-35) |
| 4 | 503, all down | banner "All systems degraded"; all cards red; API card also red |
| 5 | Network error / timeout | AppOfflineBanner + cards show last known state + "stale" badge; Retry prominent |
| 6 | 429 rate-limited | countdown on Retry (remaining seconds from Retry-After-style client math); poll paused |
| 7 | Retry tap | immediate single fetch (rate-safe: skips poll tick on success) |
| 8 | Poll tick (30 s) | silent refetch; only state changes animate (m-fast) |
| 9 | App backgrounded | poll paused; resume → immediate refetch (13 §3) |
| 10 | Tap red card | incident detail sheet (S2) from cached payload - no network |
Data mapping
| Card | Payload key | Extra | Source |
|---|---|---|---|
| API | implicit (request reached server) | - | health.controller.ts:27-37 |
| DB | data.info.mongodb.status / data.error.mongodb | - | health.controller.ts:32 |
| Redis | data.info.redis.status | ping value when up | redis-health.indicator.ts:16 |
| Storage | data.info.storage.status | - | storage-health.indicator.ts:19 |
| Queues | data.info.bullmq.status | pendingJobs (emails, in-app, webhook-deliver, dlq) | bullmq-health.indicator.ts:30 |
| Last-check | client timestamp of 200/503 response | - | - |
States
idle → loading (skeletons) → success(all-up) / success(partial) / success(all-down) ↔ error(offline/timeout, stale data) ↔ rate-limited (429 countdown, poll paused).
Permissions
(proposed) — no health.* permission exists (permissions.constants.ts:1-97).
Screen gated by admin role only; document the new permission
(health.read, proposed) for the RBAC roadmap.
Accessibility
- Overall banner is a live region announcing state changes.
- Each card:
Semantics(label: "Redis: up, ping PONG", button: true). - Red/green never the only signal - text "up"/"down" always present
(
00-shared/09).
Motion
State-change flash 150 ms (m-fast); banner swap slide 250 ms (m-base);
loading skeletons per 00-shared/08.
S2. Incident Detail Sheet (proposed)
| Field | Spec |
|---|---|
| Trigger | tap any card with down state |
| Content | failed key, raw data.error.<key> payload rendered as monospace read-only, pendingJobs for bullmq, last-check time, "no additional detail reported" when payload is bare (redis failure, redis-health.indicator.ts:17-22) |
| CTA | none beyond close; Retry lives on S1 (avoid duplicate retry paths) |
| Notes | renders exclusively from the cached fetch - zero network calls; keeps parity with endpoint truth |
07 - Component Library (Health Module)
- 1. StatusCard
- 2. StatusDot
- 3. PendingJobsTile
- 4. LastCheckedLabel
- 5. OverallBanner
- 6. IncidentSheet (S2)
- 7. Not built (flag in code)
Module-specific widgets for the Health surfaces. Authoritative shared components live in 00-shared/03 (
AppCard,AppButton,AppBadge,AppSkeleton,AppOfflineBanner,AppBanner,AppSnackbar); this file defines only what the module adds on top. All(proposed).
1. StatusCard
| Property | Spec |
|---|---|
| Purpose | One dependency's availability + optional detail line |
| Slots | leading StatusDot, title (API, DB, Redis, Storage, Queues), status text (up/down), optional detail line (ping: PONG), optional trailing chip |
| States | up (green), down (red), stale (amber + "stale" badge), loading (skeleton) |
| Semantics | `label: " |
| Usage | S1 card grid; tap target only when down (avoid dead taps) |
// lib/features/health/presentation/widgets/status_card.dart
StatusCard(
title: 'Redis',
status: ServiceStatus.up, // up | down | stale
detail: 'ping: PONG', // redis-health.indicator.ts:16
onTap: status == ServiceStatus.down ? _openIncident : null,
)
2. StatusDot
| Property | Spec |
|---|---|
| Purpose | 10 dp filled circle; color + semantics, never color alone |
| Colors | success green / error red / warning amber (design tokens 00-shared/02) |
| a11y | `Semantics(label: 'up' |
3. PendingJobsTile
| Property | Spec |
|---|---|
| Purpose | Shows BullMQ backlog - the only quantitative signal in the module |
| Data | data.info.bullmq.pendingJobs - array [emails, in-app, webhook-deliver, dlq] (bullmq-health.indicator.ts:27-30) |
| Rendering | emails 3 · in-app 0 · webhook-deliver 8 · dlq 1; dlq count highlighted amber when > 0 (dead-letter backlog is an ops smell) |
| Source note | counts are summed per queue (Object.values(r).reduce, :27-29) - waiting/active/delayed/… collapsed; label must not claim per-state detail |
4. LastCheckedLabel
| Property | Spec |
|---|---|
| Purpose | "Last checked 12:04:31" - client timestamp of the last 200/503 |
| Data | no server field exists (non-goal, 01 §5) - local clock only |
| Stale rule | > 60 s since last success → amber text + "stale" (14 QA-4) |
5. OverallBanner
| Property | Spec |
|---|---|
| Purpose | One-glance verdict at top of S1 |
| Content | "All systems operational" / "N of 5 degraded — |
| Motion | m-base slide swap; live region announce (00-shared/08) |
6. IncidentSheet (S2)
| Property | Spec |
|---|---|
| Purpose | Read-only failure detail from cached payload |
| Content | failed key, monospace SelectableText of data.error.<key>, pendingJobs when bullmq, bare-payload notice for redis (redis-health.indicator.ts:17-22) |
| Motion | bottom sheet per 00-shared/03 |
7. Not built (flag in code)
- History/uptime charts - endpoint is stateless; no data source
(forward-looking). - Threshold config widgets - no server config surface
(proposed)only.
08 - Form Specifications (Health Module)
- 1. Retry action (S1)
(proposed) - 2. Poll cadence input (S1, internal)
(proposed) - 3. Explicitly not built
(proposed)/(planned) - 4. Validation notes
The Health module has no data-entry forms. This file documents the only interactive input (Retry) and the
(proposed)configuration surface that is deliberately not built. Backend has no DTOs: the endpoint takes no body, query or params (health.controller.ts:27-37- bare@Get()+check()).
1. Retry action (S1) (proposed)
Not a form - a single async action button:
| Aspect | Spec |
|---|---|
| Trigger | tap Retry |
| Behavior | immediate single GET /api/v1/health; on success, resets the poll timer (skip next tick, 13 §2) |
| Guard | disabled while a fetch is in flight; disabled during 429 countdown (rate-limit.constants.ts:4 - 30 req/min shared with all clients of the endpoint) |
| Result | state swap per 06 S1 rows 2-4; no snackbar on success (the screen IS the result); error → offline banner |
| Semantics | Button('Retry'); announce result via banner live region |
Rate-limit math (client must respect it): poll every 30 s + manual retries must stay under 30 req/min for the whole deployment - the endpoint is public and shared with LBs (02 §1). Client-only guidance: never auto-retry a 503 in a loop; 429 → back off ≥ 60 s (14 QA-5).
2. Poll cadence input (S1, internal) (proposed)
Internal, not user-facing: poll interval fixed at 30 s foreground (13 §2). No settings screen, no user-editable interval - YAGNI; the endpoint rate cap is the constraint that makes user-configurable intervals an abuse vector, not a feature.
3. Explicitly not built (proposed) / (planned)
| Surface | Status | Why not |
|---|---|---|
| Health "thresholds" form (alert when redis down, etc.) | (proposed) | no server config; alerting out of module scope (01 §5) |
| Dependency detail form (DB name, queue picker) | (proposed) | nothing user-writable; endpoint fixed 4 indicators (health.controller.ts:31-36) |
| Liveness/readiness toggle (which probes /live runs) | (planned) | split endpoints not implemented (docs/user-flows/END_TO_END_USER_FLOWS.md:738-740); server-side concern anyway |
4. Validation notes
No text inputs → no client validation rules, no keyboard types, no autofill,
no submit semantics. If a future (proposed) config form ever lands, it must
validate client-side before submit and map server 400 details
(http-exception.filter.ts:103-108); until then: nothing to validate.
09 - User Behaviour (Health Module)
- 1. Poll, don't push
- 2. Probe → act on status code, not body
- 3. One culprit at a time
- 4. Detail is payload-conditional
- 5. Retry discipline
- 6. Trust gradient (human screen
(proposed)) - 7. Non-behaviour (explicitly out)
Observed and expected behaviours around the health surface. Machine behaviours are the implemented contract; human behaviours are
(proposed)/(forward-looking). Sources:health.controller.ts:14-37,redis-health.indicator.ts:12-23,bullmq-health.indicator.ts:19-37,rate-limit.constants.ts:4.
1. Poll, don't push
- Behaviour: consumers ask on an interval; the server never pushes health
(no WS/SSE in
health.module.ts; theWsModuleis a separate surface). - Consequence: screen polls every 30 s (13 §2); load balancers choose their own cadence ≥ 10 s (03 §5 - the 30/min cap is a hard shared budget).
2. Probe → act on status code, not body
- 200 → healthy; 503 → unhealthy. The body (
data.status,data.info/data.error) explains which dependency (health.controller.spec.ts:21-36). - Trap: the 503 error body arrives through the error envelope
(
http-exception.filter.ts:73-81) -data.error.<key>carries the down details; do not discard the body on non-2xx (14 QA-1).
3. One culprit at a time
- Partial failure keeps healthy deps
upininfo(health.controller.spec.ts:29-35). Humans read the first red card, fix that dependency, then re-check - the combined check makes cascading diagnosis quick because it always runs all four indicators (health.controller.ts:31-36), even when one fails.
4. Detail is payload-conditional
| Indicator | Detail when up | Detail when down |
|---|---|---|
| mongodb | none (pingCheck, health.controller.ts:32) | none |
| redis | ping: 'PONG' (redis-health.indicator.ts:16) | none - bare getStatus('redis', false) (:17-22) |
| storage | none (storage-health.indicator.ts:19) | none - bare (:20-24) |
| bullmq | pendingJobs array (:30) | none - bare (:31-36) |
- Behaviour: never render a "detail" section from an up-only contract; show "no additional detail reported" (06 S2).
- Ops habit: use
pendingJobswhile up as a leading indicator - growing backlog precedes hard down (03 §2).
5. Retry discipline
- Manual retry is the human's tool; auto-retry loops on 503 are the classic abuse (and hit the 30/min shared cap).
- 429 → back off 60 s, surface countdown (06 S1 row 6).
6. Trust gradient (human screen (proposed))
- Fresh + green = high trust.
- Stale + green = low trust - "stale" badge forces attention (07 §4).
- Red = the truth the screen exists to surface; no smoothing, no
"degraded but fine" copy - the endpoint says
down, the UI saysdown(health.controller.spec.ts:29-35).
7. Non-behaviour (explicitly out)
- No per-tenant health checks, no tenant-scoped filtering - endpoint is global (01 §5). Users do not expect school-level health.
- No write-back from screen to infra; screen is read-only + Retry (04 §4).
10 - Interaction Specification (Health Module)
- 1. Endpoint interaction contract (implemented)
- 2. S1 screen interactions
(proposed) - 3. State machine (S1)
- 4. Motion map
- 5. Accessibility interactions
Interaction, state and motion details for the Health surfaces. Shared interaction/motion language in 00-shared/08. Screens
(proposed); the endpoint interaction contract is implemented and quoted.
1. Endpoint interaction contract (implemented)
| Aspect | Spec | Source |
|---|---|---|
| Request | GET /api/v1/health - no params, no body, no headers beyond standard | health.controller.ts:27-37 |
| Auth | none - @Public() skips JWT/RBAC guards | health.controller.ts:15, app.module.ts:129-131 |
| Rate limit | 30 req/min sliding window, Redis-backed | health.controller.ts:16, rate-limit.constants.ts:4 |
| Success | 200, envelope-wrapped Terminus result | response-envelope.interceptor.ts:47-60 |
| Failure | 503, error envelope; 429 when rate-capped | http-exception.filter.ts:56-58, 73-81 |
| Latency | as fast as the slowest dependency probe; no timeout inside the check itself (non-goal, 01 §5) | health.controller.ts:31-36 |
2. S1 screen interactions (proposed)
| # | Interaction | Spec |
|---|---|---|
| I1 | Enter screen | immediate fetch(); skeletons for 5 cards |
| I2 | Poll tick (30 s) | silent refetch; animate only changed cards (m-fast flash) |
| I3 | Tap down-card | open S2 sheet from cached payload (zero network) |
| I4 | Tap up-card | nothing (not a button; avoids dead-affordance taps) |
| I5 | Retry tap | immediate fetch; resets poll timer on success; disabled while in-flight; countdown during 429 |
| I6 | Pull-to-refresh | optional at grid top; same as Retry (skip when I5 timer active) |
| I7 | App resume from background | immediate refetch (stale rule, 07 §4) |
| I8 | Offline | AppOfflineBanner; cards show last state + "stale"; Retry stays enabled |
3. State machine (S1)
stateDiagram-v2
[*] --> Loading
Loading --> AllUp: 200 status ok
Loading --> PartialDown: 503 error has N<5 down keys
Loading --> AllDown: 503 error has 5 down keys
Loading --> Offline: network/timeout
AllUp --> AllUp: poll tick (no change)
AllUp --> PartialDown: poll tick
PartialDown --> AllUp: retry/poll recovers
PartialDown --> Offline: retry fails w/ network error
AllUp/PartialDown/AllDown --> RateLimited: 429
RateLimited --> AllUp/PartialDown/AllDown: countdown ends → retry
Offline --> Loading: retry (connectivity restored)
Down-key counting: API card counts as down when the request itself is 503;
dependency keys come from data.error (health.controller.spec.ts:29-35).
4. Motion map
| Motion | Token | Use | Source |
|---|---|---|---|
| Card state flash | m-fast (150 ms) | status change on poll | 00-shared/08 |
| Banner swap | m-base (250 ms) | overall verdict change | 00-shared/08 |
| Sheet | bottom sheet curve | S2 open/close | 00-shared/03 |
| Skeleton shimmer | loop | loading cards | 00-shared/08 |
5. Accessibility interactions
- Retry, cards, banner: full focus traversal; cards announce
buttonwhen down (06S1 a11y). - Live region on banner for state changes (
00-shared/09). - No gestures required for any core action (no swipe-to-refresh dependency -
Retry button is the canonical path,
00-shared/09).
11 - Design System Mapping (Health Module)
- 1. Status semantics → color tokens
- 2. Typography
- 3. Spacing / layout tokens
- 4. Component mapping (all 00-shared/03 unless module-defined)
- 5. Icons
- 6. Theming notes
Maps the Health module onto the global design system. Tokens in 00-shared/02, mapping conventions in 00-shared/04, components in 00-shared/03. All screens
(proposed).
1. Status semantics → color tokens
| Semantic | Token | Hex (from 00-shared/02 palette) | Usage |
|---|---|---|---|
| Dependency up | color-success | green | StatusDot, card border, banner positive |
| Dependency down | color-error | red | StatusDot, card border, banner negative |
| Stale / caution | color-warning | amber | stale badge, dlq > 0 chip (07 §3) |
| Neutral | color-surface-* | greys | card base, skeleton |
| Text | color-text-* | on-surface | labels, detail lines |
Never color-only: every status token is paired with text (up/down/stale)
(00-shared/09, 07 §2).
2. Typography
| Element | Token | Example |
|---|---|---|
| Screen title | text-title | "App Health" |
| Banner verdict | text-body strong | "1 of 5 degraded — Redis is down" |
| Card title | text-label | "Redis" |
| Card status | text-label + color | "up" / "down" |
| Detail line | text-caption | "ping: PONG" |
| Monospace payload (S2) | text-code | data.error.redis render |
3. Spacing / layout tokens
- Card grid: 8 dp gutter (
space-2), 16 dp page padding (space-4). - 2-column grid ≥ 700 dp (00-shared/11); single column below.
- Banner: full-width
AppBanner, 12 dp internal padding.
4. Component mapping (all 00-shared/03 unless module-defined)
| Screen element | Component | Source |
|---|---|---|
| Overall verdict | AppBanner (custom OverallBanner, 07 §5) | S1 |
| Status cards | AppCard + StatusCard (07 §1) | S1 |
| Status indicator | StatusDot (07 §2) | S1/S2 |
| Queue backlog | PendingJobsTile (07 §3) | S1 |
| Last-check time | LastCheckedLabel (07 §4) | S1 |
| Retry | AppButton (outlined, m-app- style) | S1 |
| Skeletons | AppSkeleton | S1 loading |
| Offline | AppOfflineBanner | S1 |
| Detail | AppBottomSheet + SelectableText monospace | S2 |
5. Icons
| Icon | Where | Note |
|---|---|---|
Icons.favorite-style heartbeat glyph (checkmark/circle per library) | banner positive | semantic, not decorative |
Icons.refresh | Retry | standard action glyph |
Icons.info_outline | stale badge | pairs with amber |
6. Theming notes
- Health colors ride the existing semantic tokens - no new palette entries, no module-local color constants (00-shared/04).
- Dark mode: status colors keep contrast ≥ 4.5:1 against
color-surface(00-shared/09); amber-on-dark validated for stale badge. - Motion tokens per 10 §4 (00-shared/08).
12 - API Mapping (Health Module)
- 1. Health check (implemented)
- 2. Success response - 200 (all up)
- 3. Failure response - 503 (any dependency down)
- 4. Partial failure semantics (200 never happens; 503 with healthy siblings)
- 5. Error map
- 6. Planned / not yet in source
Exact wire contracts for the Health module. Base path
/api/v1(URI versioning,main.ts:44-48; prefixapi, version1-env.ts:14,16). Endpoint is@Public()(health.controller.ts:15) so the JWT/RBAC global guards (app.module.ts:129-131) are bypassed; rate-limited on thepublictier (health.controller.ts:16,rate-limit.constants.ts:4). Envelopes per 00-shared/07 §2-3.
1. Health check (implemented)
| Method | Path | Auth | Rate | Source |
|---|---|---|---|---|
| GET | /api/v1/health | public (@Public()) | 30 req/min (public tier) | health.controller.ts:27-37 |
| GET | /api/v2/health | public | 30 req/min | @Version(['1','2']), health.controller.ts:28 |
No params, no body, no query. Four indicators registered in one check
(health.controller.ts:31-36):
| Indicator key | Mechanism | Detail payload | Source |
|---|---|---|---|
mongodb | Terminus MongooseHealthIndicator.pingCheck('mongodb') | - | health.controller.ts:32 |
redis | RedisHealthIndicator.check() - PING must return PONG | { ping: 'PONG' } when up | redis-health.indicator.ts:12-23 |
storage | StorageHealthIndicator.check() - provider healthCheck() | - | storage-health.indicator.ts:16-26, storage-provider.ts:27 |
bullmq | BullMqHealthIndicator.check() - getJobCounts on emails, in-app, webhook-deliver, dlq | { pendingJobs: [n,n,n,n] } when up | bullmq-health.indicator.ts:19-37, queue.constants.ts:2,5,14, dlq.constants.ts:1 |
2. Success response - 200 (all up)
@HealthCheck() + response envelope (response-envelope.interceptor.ts:47-60);
shape per HealthCheckResult (health.controller.spec.ts:21-36):
{
"success": true,
"message": "OK",
"data": {
"status": "ok",
"info": {
"mongodb": { "status": "up" },
"redis": { "status": "up", "ping": "PONG" },
"storage":{ "status": "up" },
"bullmq": { "status": "up", "pendingJobs": [3, 0, 8, 1] }
},
"error": {},
"details": {
"mongodb": { "status": "up" },
"redis": { "status": "up", "ping": "PONG" },
"storage":{ "status": "up" },
"bullmq": { "status": "up", "pendingJobs": [3, 0, 8, 1] }
}
},
"timestamp": "2026-08-03T12:04:31.000Z",
"requestId": ""
}
pendingJobsorder = injection order:[emails, in-app, webhook-deliver, dlq](bullmq-health.indicator.ts:21-25); values are per-queue totals (Object.values(r).reduce,:27-29).requestIdechoesx-request-idheader or is""(response-envelope.interceptor.ts:44).
3. Failure response - 503 (any dependency down)
@HealthCheck throws ServiceUnavailableException; the global filter renders
the error envelope (http-exception.filter.ts:73-81). 503 is not in
STATUS_CODE_MAP (:27-35), so code falls to INTERNAL_SERVER_ERROR (:56-58):
{
"success": false,
"message": "Redis health check failed",
"error": {
"code": "INTERNAL_SERVER_ERROR",
"details": [ { "message": "Redis health check failed" } ]
},
"timestamp": "2026-08-03T12:05:02.000Z",
"requestId": ""
}
Contract note: the down dependency's key is NOT in this envelope - the
HealthCheckResult is only returned on 200. Clients get the culprit from the
last successful poll, or from the 503 message string; a (proposed) future
change should include the failing key in the error body (14 QA-3).
4. Partial failure semantics (200 never happens; 503 with healthy siblings)
The Terminus result that WOULD be returned (per health.controller.spec.ts:29-35)
keeps healthy deps up in info and lists failed keys in error:
{ "status": "error",
"info": { "mongodb": { "status": "up" }, "storage": { "status": "up" } },
"error": { "redis": { "status": "down" }, "bullmq": { "status": "down" } },
"details": { "mongodb": { "status": "up" }, "redis": { "status": "down" }, … } }
5. Error map
| Code | Meaning | Source |
|---|---|---|
| 200 | all indicators up | health.controller.ts:31-36 |
| 503 | ≥ 1 indicator down (Terminus throws) | @HealthCheck() decorator |
| 429 | rate cap - 30 req/min sliding window, Redis-backed | rate-limit.constants.ts:4, IMPLEMENTATION_PLAN.md:48 |
| 400 | malformed request path/version edge | main.ts:50-57 (global pipes) |
6. Planned / not yet in source
| Path / feature | Status | Source |
|---|---|---|
GET /api/v1/health/ready (readiness) | (planned) | docs/user-flows/END_TO_END_USER_FLOWS.md:739 - no route in health.controller.ts |
GET /api/v1/health/live (liveness) | (planned) | docs/user-flows/END_TO_END_USER_FLOWS.md:740 - no route in health.controller.ts |
| Disk + memory indicators | (planned) | Terminus supports (DiskHealthIndicator/MemoryHealthIndicator); not wired - health.controller.ts:31-36 |
| Uptime / version / build hash in payload | (planned) | HealthCheckResult returned verbatim - health.controller.ts:30-37 |
Auth'd admin health variant + health.* permission | (proposed) | no health.* in permissions.constants.ts:1-97 (verified) |
| Failing-key in 503 error body | (proposed) | http-exception.filter.ts:56-58, 73-81 |
13 - State Management (Health Module)
- 1. Cubit map
- 2. HealthCubit (core)
- 3. Models
- 4. Error envelope handling (503 is data, not failure)
- 5. Cross-cutting
Per-screen Cubit/Bloc design on top of 00-shared/06 conventions (stack:
flutter_bloc+get_it; server state via dio repository;LoadState= Initial/Loading/Success/Error(ApiException)). Screens(proposed). Backend truth: single stateless endpoint (health.controller.ts:27-37) - all freshness logic lives client-side.
1. Cubit map
| Cubit | Screen (05) | Data |
|---|---|---|
HealthCubit | 1 (App Health) | HealthSnapshot (per-dependency status + extras + freshness), poll timer, retry |
| (none for S2) | 2 (Incident sheet) | renders from HealthCubit cached payload - no own cubit |
Repository (HealthRepository in features/health/data/) is the only layer
touching HTTP; it maps both the 200 envelope and the 503 error envelope to
models and throws ApiException(status, message) (00-shared/06 §2-3).
2. HealthCubit (core)
stateDiagram-v2
[*] --> Initial
Initial --> Loading: fetch()
Loading --> AllUp(snapshot): 200 envelope, status ok
Loading --> PartialDown(snapshot): 503 error, <5 down
Loading --> AllDown(snapshot): 503 error, 5 down
Loading --> Error(offline): network/timeout (LoadState.Error)
AllUp/PartialDown/AllDown --> Polling: timer starts (30 s)
Polling --> AllUp/PartialDown/AllDown: silent refetch (timer reset)
Polling --> RateLimited: 429 → pause timer, countdown
RateLimited --> Polling: 60 s elapsed → refetch
Polling --> Error: network error (keep last snapshot + stale flag)
Error --> Polling: retry()/connectivity regained
- Polling:
Timer.periodic(30 s)while foregrounded; canceled on background (AppLifecycleState.paused) and restarted with an immediatefetch()on resume (10I7). 30 s chosen to stay far under the 30 req/min public cap shared with LBs (12§5,rate-limit.constants.ts:4). - Retry: manual
retry()= single immediate fetch; on success resets the poll timer (skip next tick). In-flight guard: one request at a time (08§1).
3. Models
class HealthSnapshot {
final DateTime checkedAt; // client clock (server sends none - 12 §6)
final Map<String, DependencyStatus> deps; // mongodb, redis, storage, bullmq
final bool reachable; // HTTP 200 received
final bool rateLimited; // 429 seen
final List<int>? pendingJobs; // bullmq only: [emails, in-app, webhook-deliver, dlq]
final String? ping; // redis only: 'PONG'
}
enum DependencyStatus { up, down, stale } // stale = last data older than 60 s
- Source mapping:
data.info.<key>.status→up;data.error.<key>→down(keys present ininfostayupon partial failure,health.controller.spec.ts:29-35); extras fromdata.info.redis.ping(redis-health.indicator.ts:16) anddata.info.bullmq.pendingJobs(bullmq-health.indicator.ts:30). - API card is
reachable(request reached server) - no explicit key.
4. Error envelope handling (503 is data, not failure)
HealthRepository treats 503 as a successful fetch of a down state, not an
exception: it maps the error envelope (http-exception.filter.ts:73-81) into a
HealthSnapshot with all keys down + reachable: false. Only network
errors/timeouts/5xx-without-health-shape become LoadState.Error. This is the
single most important client decision in the module (09 §2, 14 QA-1).
5. Cross-cutting
- No cache layer: the screen is its own freshest state; no persistence - restarting the app refetches. Stateless endpoint = nothing to warm.
- No domain events: the client does not consume any event stream for
health (
event-queue-maphas no health events); polling is the only source. - Rate budget: poll (30 s) + manual retry must respect the shared 30
req/min public cap; on 429 the cubit pauses polling 60 s (
12§5). - Permission gating: screen gating is
(proposed)- nohealth.*permission exists (permissions.constants.ts:1-97); when added, gateHealthCubitstart on it. - Planned: liveness/readiness split polling per endpoint when
/live+/readyland(planned)-docs/user-flows/END_TO_END_USER_FLOWS.md:738-740.
14 - QA Checklist (Health Module)
- 1. Contract tests (endpoint)
- 2. Failure modes
- 3. Client behaviours (proposed screen)
- 4. Security / leakage
- 5. Load-shedding / performance
- 6. Planned / forward-looking (flag in tests)
Verification checklist for the Health module. Baseline in 00-shared/10. Items marked with the failure class they catch; every item maps to a source line so tests stay honest to the contract.
1. Contract tests (endpoint)
| # | Check | Expectation | Source |
|---|---|---|---|
| C1 | All-up request | 200; data.status: 'ok'; 4 keys in info; error: {} | health.controller.spec.ts:21-36, health.controller.ts:31-36 |
| C2 | Indicator count | exactly mongodb, redis, storage, bullmq - no more, no less | health.controller.ts:31-36 |
| C3 | Redis detail | info.redis.ping === 'PONG' on success | redis-health.indicator.ts:12-23 |
| C4 | BullMQ detail | info.bullmq.pendingJobs is a 4-element int array in injection order [emails, in-app, webhook-deliver, dlq] | bullmq-health.indicator.ts:21-30, queue.constants.ts:2,5,14 |
| C5 | Version both | /api/v1/health and /api/v2/health both 200 | @Version(['1','2']), health.controller.ts:28 |
| C6 | Auth bypass | request without any Authorization header → 200 (not 401/403) | @Public(), health.controller.ts:15; guards app.module.ts:129-131 |
| C7 | Rate cap | > 30 req/min → 429 | rate-limit.constants.ts:4 |
| C8 | Envelope | success body has success/message/data/timestamp/requestId | response-envelope.interceptor.ts:47-60 |
| C9 | No DTO | no validation 400 on any query/body (none accepted) | health.controller.ts:27-37 |
2. Failure modes
| # | Scenario | Expected | Gaps to flag |
|---|---|---|---|
| F1 | Redis down | 503; data.error.redis.status === 'down' in the result Terminus built; error envelope code: 'INTERNAL_SERVER_ERROR' (503 unmapped in filter) | error body does not carry the key - http-exception.filter.ts:56-58 |
| F2 | Partial - Redis + BullMQ down, Mongo + storage up | 503; info keeps healthy keys up | verify client never blanks healthy cards (13 §4) |
| F3 | All four down | 503; all keys in error | - |
| F4 | Storage provider throws | indicator catches → HealthCheckError → down | storage-health.indicator.ts:16-26 |
| F5 | Redis PING returns non-PONG | down (status computed, :15) | - |
| F6 | Queue unreachable (Redis-backed queues) | getJobCounts rejects → HealthCheckError → down | bullmq-health.indicator.ts:31-36 |
| F7 | BullMQ partial queue failure | Promise.all fails whole indicator → all-or-nothing; no per-queue down granularity | acceptable; document |
3. Client behaviours (proposed screen)
| # | Check | Expectation |
|---|---|---|
| G1 | 503 mapped to down-state, not error state | HealthRepository maps error envelope → HealthSnapshot(down); never a red error page (13 §4) |
| G2 | Partial failure rendering | red only on culprit cards; healthy cards stay green (06 S1 row 3) |
| G3 | Stale guard | snapshot > 60 s old renders stale + amber, never fresh-green (07 §4) |
| G4 | Poll pause/resume | backgrounded → timer canceled; resume → immediate refetch (13 §2) |
| G5 | 429 handling | poll pauses 60 s; retry disabled with countdown (10 I5) |
| G6 | In-flight guard | double-tap Retry issues exactly one request |
| G7 | PendingJobs drift | dlq > 0 shows amber chip (07 §3) |
| G8 | Detail sheet | renders from cache; zero network calls on open (06 S2) |
4. Security / leakage
| # | Check | Expectation |
|---|---|---|
| S1 | No secret leakage | payload carries only statuses + counts; no Redis URLs/credentials, queue connection strings, storage keys |
| S2 | Public endpoint abuse | 30 req/min cap enforced; no auth bypass of other endpoints via @Public() scope creep (health.controller.ts:15 is controller-scoped only) |
| S3 | No tenant data | response must never contain tenant names/ids; health is global (01 §5) |
| S4 | DoS via hanging probe | check has no internal timeout - a hung dependency stalls the request until client/middleware timeout; monitor p99 of /health |
5. Load-shedding / performance
| # | Check | Expectation |
|---|---|---|
| L1 | Probe storm | 30 concurrent probes → all served within rate window; no queue buildup (each probe runs 4 checks in parallel, health.controller.ts:31-36) |
| L2 | Indicator parallelism | 4 checks run concurrently (Promise batch via HealthCheckService) - total latency ≈ slowest check, not the sum |
| L3 | Monitor drift | pendingJobs in info is a sum, not per-state; alert thresholds must not be built on it (bullmq-health.indicator.ts:27-29) |
6. Planned / forward-looking (flag in tests)
| # | Item | Status |
|---|---|---|
| P1 | /health/live + /health/ready route tests | (planned) - END_TO_END_USER_FLOWS.md:738-740 |
| P2 | Disk/memory indicator tests | (planned) - not wired |
| P3 | Uptime/version fields | (planned) - absent from payload |
| P4 | Failing-key in 503 body | (proposed) - filter change needed (http-exception.filter.ts:56-58) |
| P5 | Authenticated admin variant | (proposed) - needs health.* permission (permissions.constants.ts:1-97) |
15 - Flutter Implementation Guide (Health Module)
- 1. Folder layout
- 2. Repository (the critical mapping)
- 3. HealthCubit
- 4. Screen & widgets
- 5. Key implementation details
- 6. Tests
- 7. Analytics (proposed)
- 8. Roadmap items NOT built (flag in code)
Build order and concrete Flutter implementation notes for the Health module client, on top of 00-shared/11 (app architecture) and 00-shared/06 (state). Reminder: per PRD the native app is post-Phase 1 (
PRODUCT_REQUIREMENTS_DOCUMENT.md:144); this guide is the forward-looking build plan.
1. Folder layout
lib/features/health/
data/
models/health_snapshot.dart # 13 §3 (deps, reachable, rateLimited,
# pendingJobs, ping, checkedAt)
repositories/health_repository.dart
domain/
entities/service_status.dart # enum up | down | stale
presentation/
cubits/health_cubit.dart # poll timer + fetch/retry (13 §2)
screens/app_health_screen.dart
widgets/ (status_card.dart, status_dot.dart, pending_jobs_tile.dart,
last_checked_label.dart, overall_banner.dart, incident_sheet.dart)
2. Repository (the critical mapping)
fetch()→GET /api/v1/health(health.controller.ts:27-37).- 200 → parse
data(HealthCheckResult):info.<key>.status,error.<key>(health.controller.spec.ts:21-36); extrasping(redis-health.indicator.ts:16),pendingJobs(bullmq-health.indicator.ts:30). - 503 → parse the error envelope (
http-exception.filter.ts:73-81) as a down snapshot -reachable: false, all keysdown; do NOT throw. Only network errors/timeouts and non-health-shaped 5xx throwApiException(13§4). - 429 → surface
rateLimitedso the cubit pauses polling 60 s (rate-limit.constants.ts:4). checkedAtis client-clock (server sends none,12§6).
3. HealthCubit
class HealthCubit extends Cubit<HealthState> {
HealthCubit(this._repo) : super(HealthState.initial());
Timer? _poll;
static const pollInterval = Duration(seconds: 30);
Future<void> start() async {
await fetch(); // immediate on screen entry
_poll = Timer.periodic(pollInterval, (_) => fetch(silent: true));
}
void onLifecyclePaused() => _poll?.cancel(); // 10 I7
void onLifecycleResumed() { fetch(); start(); }
Future<void> retry() async { if (state.inFlight) return; await fetch(); }
// fetch(silent: true) does not animate unchanged cards; 429 → pause 60 s
@override
void close() { _poll?.cancel(); super.close(); }
}
- One in-flight request at a time (guard in
fetch). - On 429: cancel poll,
Timer(60 s, () => fetch())(13§2).
4. Screen & widgets
AppHealthScreen:BlocBuilder<HealthCubit, HealthState>→OverallBanner+ 5StatusCards (grid, 2-col ≥ 700 dp) +LastCheckedLabel+ RetryAppButton.StatusCard(up/down/stale + detail; tap only whendown→IncidentSheetfrom cached snapshot - zero network,06S2).PendingJobsTile: labelemails n · in-app n · webhook-deliver n · dlq n; amber chip whendlq > 0(07§3). Parse strictly; unknown → neutral.- Monospace payload in
SelectableTextwith copy for the incident sheet.
5. Key implementation details
- Poll hygiene: 30 s + manual retry must stay under the shared 30 req/min
public cap (
08§1); never auto-retry 503 in a loop (09§5). - Status enum:
up/down/stale; stale = snapshot > 60 s (07§4). Color never alone - text always (00-shared/09). - Version note: client should call
/api/v1/health;/v2exists and is identical today (health.controller.ts:28) - pick one and keep it. - No auth header needed - endpoint is
@Public()(health.controller.ts:15); the dio client must not attach the JWT interceptor (keeps probes clean). - Permission gating (proposed): when
health.read-style permission lands (permissions.constants.ts:1-97has none today), gate screen entry on it.
6. Tests
- Unit: model fromJson (200 envelope, extras
ping/pendingJobs,errorkeys), status enum parsing, stale computation. - Repository: 503 error-envelope → down snapshot (NOT exception);
429 →
rateLimited; network error →ApiException. - Cubit: initial fetch; poll tick silent refresh; 429 pause/resume;
in-flight guard; lifecycle pause/resume refetch (
13§2). - Widget: card states up/down/stale; banner text per state; detail sheet renders from cache; dlq amber chip.
- Integration (
00-shared/10): against running API - all-up 200; force-stop Redis in test env → 503; verify no secret/connection-string strings in any payload (14S1).
7. Analytics (proposed)
Wire health.screen.{open,poll_success,poll_failure}, health.retry.tap,
health.card.detail_open from 05; no SDK selected yet (00-shared/10 §8).
8. Roadmap items NOT built (flag in code)
- Liveness/readiness split screens/polling - wait for
/live+/ready(planned),END_TO_END_USER_FLOWS.md:738-740. - Disk/memory cards - not wired server-side
(planned),health.controller.ts:31-36. - Uptime/version display - payload absent
(planned),12§6. - Authenticated admin health variant
(proposed)- nohealth.*permission yet (permissions.constants.ts:1-97).
01 — Product Overview (Homework Module)
- 1. Purpose
- 2. Business goals
- 3. User goals
- 4. Stakeholders
- 5. Why this exists
- 6. Dependencies
- 7. Success metrics
- 8. Edge cases
- 9. Assumptions (module)
- 10. Open questions (module-level; global ledger in 00-shared/12)
- 11. Glossary (this module)
StudyLyon — multi-tenant ERP / School Management API. This package designs the Homework module client (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, domain events, queue routes, and wire contracts are derived directly from
src/modules/homework/**,src/modules/files/**,src/modules/notifications/**,src/infrastructure/bullmq/**,src/modules/rbac/permissions.constants.ts,studylyon-blueprint/04-Modules/Homework.md,PLAN.md §6, anddocs/IMPLEMENTATION_PLAN.md. No feature is invented; gaps are flagged in Assumptions & Open Questions.
1. Purpose
Homework is the assignment lifecycle: a teacher creates an assignment for one class and subject with a due date and optional file attachments; a student submits work (remarks + optional attachments) exactly once per homework; the teacher grades the submission with marks + remarks; the parent sees pending and graded homework (read-only; no parent API exists yet — OQ-5). Every state change emits a domain event that is routed over BullMQ to in-app notifications.
| Responsibility | Source |
|---|---|
| Create homework (teacherId, classId, subjectId, title, description?, attachments?, dueDate) | homework.service.ts:27-43 create() |
| List homework for a class (sorted dueDate desc) | homework.repository.ts:17-21 |
| Get / update / soft-delete homework | homework.service.ts:45-81 |
| Accept exactly-once student submission (409 on duplicate) | homework.service.ts:83-112 submit() |
| List submissions of a homework | homework-submission.repository.ts:20-24 |
| Grade a submission (marks, remarks, status→graded, gradedAt) | homework.service.ts:120-145 gradeSubmission() |
| File attachments (upload/download/delete via FilesModule) | files.controller.ts:29-71, files.service.ts:27-70 |
Domain events → BullMQ in-app / audit-write queues | event-queue-map.ts:22-26, inapp.worker.ts, queue-bridge.service.ts:40-75 |
| Multi-tenant scoping + soft-delete filtering on every query | base.repository.ts:20-30 |
2. Business goals
| Goal | Measure |
|---|---|
| Zero lost submissions | unique index {tenantId, homeworkId, studentId} + service-level 409 (homework-submission.schema.ts:37-40, homework.service.ts:92) |
| Teacher grading turnaround | graded state visible immediately after PATCH grade (homework.service.ts:127-134) |
| Class-level visibility | GET /homework/class/:classId scoped + sorted by due date (homework.repository.ts:19-20) |
| Cross-tenant isolation | every query tenant-scoped via BaseRepository.scopedFilter (base.repository.ts:24-29) |
| Audit trail of homework changes | HomeworkUpdated/HomeworkDeleted events → audit-write queue (event-queue-map.ts:23,26) |
3. User goals
- Teacher: create a homework with a due date and attachments in seconds; see which students submitted; grade with marks + remarks and re-grade when needed.
- Student: see pending homework for my class; submit exactly once (with optional attachment); see my marks and teacher feedback.
- Parent (read): follow child's pending homework and grades.
- Org admin: oversee homework activity; nothing admin-specific exists in the API (OQ-7).
4. Stakeholders
Class teachers, students, parents, subject coordinators (read), org admin, notification delivery pipeline (in-app worker), audit/compliance (event log), QA + design + engineering.
5. Why this exists
Homework is a daily, high-frequency academic workflow. The backend implements the full CRUD + submit + grade loop with strict one-submission-per-student semantics; the client must make this loop frictionless (upload progress, due-date awareness, grade feedback) without ever violating the server's invariants (no resubmission, no marks editing without a grade call).
6. Dependencies
| Dependency | Role | Source |
|---|---|---|
| Files module | attachment upload/download/delete; file.upload/read/delete perms | files.controller.ts:30,44,50,56,67 |
| Storage provider | object storage behind STORAGE_PROVIDER token | storage-provider.ts:21-27 |
| Notifications module | in-app notification read API for homework events | notifications.controller.ts:21-47 |
BullMQ in-app queue | HomeworkCreated/Updated/Submitted/Graded → notification jobs | event-queue-map.ts:22-25 |
BullMQ audit-write queue | HomeworkDeleted → audit log job | event-queue-map.ts:26 |
| Academic modules | Class, Subject, Student, Teacher referenced ids | homework.schema.ts:9-16, homework-submission.schema.ts:9-13 |
| Mongo collections | homework, homework_submissions | blueprint 03-Database/COLLECTIONS.md:1900-1958 |
7. Success metrics
- Homework creation → visible to class < 2 s (create + list round-trip).
- Submission success rate ≥ 99.5% of attempts (excl. intended 409 duplicates).
- Duplicate-submission 409 handled gracefully 100% of the time (never a crash/blank).
- Graded feedback visible immediately after
PATCH …/gradesuccess. - Zero cross-tenant leaks in lists/details (tenant scope on every query).
8. Edge cases
- Duplicate submission → 409
DUPLICATE_RESOURCE"Already submitted." (homework.service.ts:92) — also structurally enforced by unique index (homework-submission.schema.ts:37-40). - Submit to unknown homework → 404
RESOURCE_NOT_FOUND"Homework not found." (homework.service.ts:87,47). - Grade unknown submission → 404 "Submission not found." (
homework.service.ts:126,135). - Update/delete unknown homework → 404 (
homework.service.ts:56-58,71-72). - Late submission: the server does not check
dueDateon submit — late submissions are accepted (OQ-1). The UI must show a late badge derived client-side. - Regrading:
PATCH …/gradeis repeatable; each call overwrites marks/remarks, resetsgradedAt, and emits anotherHomeworkGraded(OQ-2). - Closed homework:
status: 'closed'is settable via update (homework.dto.ts:55-58), but nothing enforces it — submit/grade still work on closed homework (OQ-3). - Marks validation:
GradeSubmissionDto.markshas no class-validator decorator (submission.dto.ts:20-21) — negative/non-numeric values are not blocked at the DTO layer (OQ-4). - Delete homework: soft-delete only (
base.repository.ts:68-74); submissions are not cascaded (no cascade inremove(),homework.service.ts:70-81). - Attachment limits: no size/mime limits in code (
files.controller.ts:39,files.service.ts:27-46) — client must enforce (see 14_QA_Checklist.md). - Update semantics: only
title, description, attachments, dueDate, statusare updatable;classId/subjectId/teacherIdare immutable after create (homework.dto.ts:35-58vs4-33). - findByClass sort: newest due date first (
dueDate: -1,homework.repository.ts:20).
9. Assumptions (module)
- Mobile client is forward-looking: backend is complete; this package is the UI-side spec (00-shared/12 A1).
- "Student sees homework scoped to my class" (PLAN.md:67, 6.2) has no dedicated
endpoint — the client resolves the student's
classIdfrom the Students module profile, then callsGET /homework/class/:classId(OQ-6). attachments: string[]on homework and submissions are file record ids returned byPOST /files/upload(files.service.ts:27-46); the blueprint's storage pathsl/{tenantId}/homework/{uuid}(Homework.md:58) is plan-only — actual storage names files${randomUUID()}--${originalname}(files.service.ts:31).- The blueprint's domain-event table (Homework.md:37-41:
HomeworkAssigned,HomeworkSubmitted) is stale; the code emitsHomeworkCreated/Updated/Deleted/ Submitted/Graded(homework.service.ts:34,59,73,99,136). Code wins. - Blueprint "Reminders and due alerts" (Homework.md:17) and "Overdue detection via scheduled job" (Homework.md:60) are not implemented — no scheduler/worker exists (OQ-1).
HomeworkCreated/Updated/Submitted/Gradedare routed to thein-appqueue (event-queue-map.ts:22-25), butNotificationTypeenum (notification.schema.ts:7-12) does not contain these values — notification persistence currently fails Mongoose enum validation; treat the in-app notification surface as(planned)until the enum is extended (OQ-8).- Homework endpoints are guarded only by
JwtAuthGuard(homework.controller.ts:19) — no@Permissions()metadata and nohomework.*permissions exist (permissions.constants.ts:1-97). Any authenticated user can call every homework endpoint today (OQ-9).
10. Open questions (module-level; global ledger in 00-shared/12)
| # | Item | Impact |
|---|---|---|
| OQ-1 | No due-date enforcement and no overdue scheduler exists — late/duplicate-submission-after-due are accepted silently | Late-submission UI semantics, badge copy, future enforcement |
| OQ-2 | Grading can be repeated with no regrade guard — each call emits HomeworkGraded | Regrade confirmation UX; audit noise |
| OQ-3 | status: 'closed' is not enforced anywhere | "Closed" filter vs. submit/grade gating in UI |
| OQ-4 | marks has no validation decorator (submission.dto.ts:20-21) | Client-side range validation only; flag to backend |
| OQ-5 | No parent-facing homework API (IMPLEMENTATION_PLAN.md:227 "parent view" is (planned)) | Parent persona is read-only via… nothing yet |
| OQ-6 | No GET /homework (student's own class) endpoint despite PLAN.md:67 | Student list resolves classId from student profile |
| OQ-7 | No admin/dashboard homework endpoints | Org admin has no homework surface beyond notifications |
| OQ-8 | NotificationType enum lacks homework event types — in-app homework notifications fail validation today (inapp.worker.ts:46-53) | Notification badge/detail UX (planned) |
| OQ-9 | No RBAC on homework endpoints; no homework.* perms in ALL_PERMISSIONS (permissions.constants.ts) despite blueprint intent (Homework.md:66-71) | Role-gated UI must wait for server-side perms |
| OQ-10 | No homework emails: email.worker.ts:40-42 warns "No handler" for unknown event types | Email reminders (planned) |
11. Glossary (this module)
| Term | Meaning |
|---|---|
| Homework | homework doc: assignment scoped to teacherId+classId+subjectId, dueDate, optional attachments, status active/closed |
| Submission | homework_submissions doc: one per (homework, student); status submitted/graded; marks optional |
| Attachment | string[] of files record ids (uploaded via FilesModule) |
| Graded | submission status after PATCH …/grade; sets marks, remarks, gradedAt |
| Envelope | {success,message,data,meta?,timestamp,requestId} (response-envelope.interceptor.ts:49-52) |
| 409 Duplicate | exactly-once submission semantics: service check + unique index |
02 — User Personas (Homework Module)
- 1. Teacher (subject/class teacher) — primary creator & grader
- 2. Student — single-time submitter
- 3. Parent — read-only monitor
- 4. Org Admin — overseer
- 5. Platform Admin (cross-tenant support)
- 6. Role → homework-appearance matrix
Roles, goals, permissions, and expected behavior for every consumer of the Homework module. Permission values are exact from
rbac/permissions.constants.ts(ALL_PERMISSIONS). Homework endpoints today require only a valid JWT (homework.controller.ts:19@UseGuards(JwtAuthGuard)) — nohomework.*permission exists (OQ-9 of 01). Files endpoints are the exception:file.upload/file.read/file.delete(files.controller.ts:30,44,50,56,67).
1. Teacher (subject/class teacher) — primary creator & grader
| Attribute | Value |
|---|---|
| Frequency | Daily; creates 1–5 homework per class per week, grades in batches |
| Permissions | JWT only for homework CRUD/submit/grade today (homework.controller.ts:19); file.upload for attachments (files.controller.ts:30) |
| Goal | Assign homework with a clear due date; see submission status per student; grade with marks + remarks; re-grade when a student asks |
| Pain points | Late submissions mixed with on-time ones (no server late flag — OQ-1); repeated grading accidentally double-emits events; no bulk grade endpoints |
| Expected | Class-filtered homework list sorted by due date (homework.repository.ts:20); submission list per homework; grade sheet that persists on error (never lose typed marks) |
| Mental model | "I post it, they submit once, I mark it." |
| Core jobs | POST /homework (create), GET /homework/class/:classId (list), GET /homework/:id/submissions (grade sheet), PATCH /homework/:id/submissions/:submissionId/grade (grade), PATCH /homework/:id (edit), DELETE /homework/:id (remove) |
2. Student — single-time submitter
| Attribute | Value |
|---|---|
| Frequency | Daily check; submit before due date |
| Permissions | JWT only; no special student scoping server-side (any JWT may submit any homework with any studentId — submit() trusts dto.studentId, homework.service.ts:83-112) |
| Goal | See pending homework for my class, submit exactly once with optional attachment, see my marks + teacher remarks |
| Pain points | No per-student "my homework" endpoint (PLAN.md:67 is not implemented — OQ-6); duplicate tap on submit → 409; no server-side late flag |
| Expected | My-class list resolved via student profile classId → GET /homework/class/:classId; submit button that disables after success; grade feedback row when status: 'graded' (homework-submission.schema.ts:24) |
| Mental model | "Submit once, done. Teacher's feedback appears in the same place." |
| Core jobs | GET /homework/class/:classId, GET /homework/:id, POST /homework/:id/submit, GET /files/:id/download |
3. Parent — read-only monitor
| Attribute | Value |
|---|---|
| Frequency | Weekly; check pending/graded homework of children |
| Permissions | student.read (role seed intent, role.schema.ts); no homework API exists for parents — "parent view" is (planned) (IMPLEMENTATION_PLAN.md:227) |
| Goal | Know what's assigned, whether the child submitted, and the grade |
| Pain points | Nothing to call today (OQ-5); relies on notifications (planned) and the child's device |
| Expected | Deep link to homework detail from notification; read-only rendering (no submit/grade controls) |
| Mental model | "Is it done, and what did they get?" |
| Core jobs | (planned) GET /homework/class/:classId + GET /homework/:id read-only; today: notification feed only |
4. Org Admin — overseer
| Attribute | Value |
|---|---|
| Frequency | On demand (complaints, audits) |
| Permissions | JWT only; audit.read exists in ALL_PERMISSIONS (permissions.constants.ts:54) but no homework admin surface |
| Goal | See what homework exists in a class, who graded what, intervene (edit/delete) |
| Pain points | No dashboard/aggregate homework endpoints (OQ-7) |
| Expected | Class homework list + submission list via the same generic endpoints; delete as cleanup tool |
| Mental model | "Same screens as a teacher, with delete power." |
5. Platform Admin (cross-tenant support)
| Attribute | Value |
|---|---|
| Permissions | isPlatformAdmin bypasses tenant scope in scopedFilter (base.repository.ts:21-23) |
| Behavior | Can read any tenant's homework via the same endpoints; support-diagnostics only |
6. Role → homework-appearance matrix
| Value | Teacher | Student | Parent | Org admin |
|---|---|---|---|---|
| Create homework form | ✓ | — | — | ✓ |
| Edit / delete homework | ✓ | — | — | ✓ |
| Class homework list | ✓ (own classes) | ✓ (my class, resolved) | (planned) | ✓ |
| Submission form | — | ✓ | — | — |
| Grade sheet | ✓ | — | — | — |
| Grade feedback view | ✓ | ✓ (own only, client-gated) | (planned) | ✓ |
| Attachments upload | ✓ (file.upload) | ✓ (file.upload) | — | ✓ |
| Attachment download | ✓ (file.read) | ✓ (file.read) | (planned) | ✓ |
Honesty note: the server does not distinguish these roles on homework endpoints today (JWT-only guard, OQ-9). The matrix is the intended product behavior the client should implement with role-based UI gating (hide create/grade controls for students), which becomes authoritative the moment
@Permissions('homework.*')land.
03 — User Journeys (Homework Module)
- 1. Teacher creates homework with attachment (PLAN.md 6.1)
- 2. Student views my-class homework (PLAN.md 6.2)
- 3. Student submits homework (PLAN.md 6.3)
- 4. Teacher grades + feedback (PLAN.md 6.4)
- 5. Edit / delete homework (PLAN.md 6.5)
- 6. Late submission handling
- 7. Parent sees pending / grade (read-only)
- 8. Cross-cutting
End-to-end journeys computed from
homework.controller.ts,homework.service.ts,files.controller.ts,event-queue-map.tsandPLAN.md §6.1–6.5. Each journey: entry, intent, decision points, system responses, loading, failures, recovery, exit, abandonment, offline.(planned)/(forward-looking)marks per global rules.
1. Teacher creates homework with attachment (PLAN.md 6.1)
entry: Homework tab → FAB "New homework"; intent: assign work to a class
sequenceDiagram
actor T as Teacher
participant F as CreateFormScreen
participant R as HomeworkRepository (client)
participant FILE as POST /files/upload
participant API as POST /api/v1/homework
participant Q as BullMQ in-app queue
T->>F: class, subject, title, description, dueDate, attach file
F->>FILE: multipart upload (progress %)
FILE-->>F: file record {_id, originalName, size, mimeType} (files.service.ts:27-46)
F->>R: submit {teacherId, classId, subjectId, title, description?, attachments:[_id], dueDate}
R->>API: POST /api/v1/homework (envelope data = homework doc)
API-->>R: 201 homework doc (status 'active', assignedDate now)
API->>Q: HomeworkCreated {homeworkId, classId} (homework.service.ts:34-41)
R-->>F: success → navigate detail
F-->>T: "Homework assigned" snackbar
- Decision point: due date picker min = today (client-side; server only validates
IsDateString,homework.dto.ts:31-32). - Attachments: upload first via FilesModule, then reference
_idinattachments[]. Upload is a separate request — a failure mid-upload must not create the homework. - Failure: 400
VALIDATION_ERROR(missingtitle/dueDate/MongoIds,homework.dto.ts:4-33) → field errors; 5xx →AppErrorState+ retry (form state kept). - Exit: success → detail screen; abandonment → draft resume (client memo,
(proposed)). - Offline: creation blocked (write queue not defined for homework create), attachment
upload queued with retry per
00-shared/07 §10.
2. Student views my-class homework (PLAN.md 6.2)
entry: Homework tab; intent: see what's due and my submission status
sequenceDiagram
actor S as Student
participant P as Student profile (Students module)
participant L as HomeworkListScreen
participant API as GET /api/v1/homework/class/:classId
S->>L: open Homework tab
L->>P: my classId (cached profile)
L->>API: GET /homework/class/{classId}
API-->>L: data[] sorted dueDate desc (homework.repository.ts:20)
L-->>S: cards: title, subject, due date, status badge, my submission state
- No dedicated student endpoint — classId resolution from the profile is the OQ-6
workaround (PLAN.md:67 lists
GET /api/v1/homeworkas intended; not implemented). - Per-card my-state: client cross-checks against
GET /homework/:id/submissions(only feasible for a single homework; full per-student state is(planned)— OQ-6). - Failure: 404 →
AppEmptyState; 5xx →AppErrorState+ retry; pull-to-refresh. - Loading:
AppSkeleton(list); cache TTL 5 min (volatile — due dates change).
3. Student submits homework (PLAN.md 6.3)
entry: homework detail → "Submit" (only if not already submitted)
sequenceDiagram
actor S as Student
participant D as HomeworkDetailScreen
participant R as HomeworkRepository
participant FILE as POST /files/upload
participant API as POST /api/v1/homework/:id/submit
S->>D: open homework detail, compose remarks + attach file
D->>FILE: upload (progress, retryable)
FILE-->>D: file record id
D->>R: submit {studentId, remarks?, attachments?}
R->>API: POST /api/v1/homework/{id}/submit
API-->>R: 201 submission doc {status:'submitted', submittedAt now} (homework.service.ts:93-98)
API->>Q: HomeworkSubmitted {homeworkId, studentId, submissionId} (homework.service.ts:99-110)
R-->>D: success → detail shows "Submitted" state
D-->>S: "Submitted" confirmation + lightImpact
- Exactly-once: double-tap / retry → second call returns 409
DUPLICATE_RESOURCE"Already submitted." (homework.service.ts:92) — client maps to "Already submitted" info state, not an error. - Submit button disabled permanently after success (server truth; no optimistic
write — the doc id comes from the server,
00-shared/07 §9). - Failure: 404 homework deleted meanwhile →
RESOURCE_NOT_FOUNDempty state; network loss mid-upload → upload pending/retry, no silent corruption (10 §2). - Offline: submit blocked; guidance + draft kept locally (offline queue
(planned)).
4. Teacher grades + feedback (PLAN.md 6.4)
entry: homework detail → "Submissions" tab → tap student row → grade sheet
sequenceDiagram
actor T as Teacher
participant G as GradingSheetScreen
participant R as HomeworkRepository
participant API as PATCH /api/v1/homework/:id/submissions/:submissionId/grade
T->>G: open submission, view attachments + remarks
G->>R: grade {marks, remarks?}
R->>API: PATCH …/grade
API-->>R: 200 updated submission {status:'graded', gradedAt, marks, remarks}
API->>Q: HomeworkGraded {homeworkId, submissionId, marks} (homework.service.ts:136-143)
R-->>G: row flips to "Graded" (marks visible)
G-->>T: snackbar "Graded" + advance to next ungraded row (proposed)
- Regrade: grading an already-graded submission overwrites and emits another
HomeworkGraded(OQ-2) — confirm dialog on regrade ("This will overwrite the existing grade"). - Validation: client enforces
marks ≥ 0and ≤ max marks (configurable; server has no decorator —submission.dto.ts:20-21, OQ-4). - Failure: 404 "Submission not found." (
homework.service.ts:126) → row refresh; network loss → grade sheet keeps typed values, retry offered. - No optimistic grading (side effect + notification event): server-confirmed only.
5. Edit / delete homework (PLAN.md 6.5)
entry: homework detail → menu → Edit / Delete
sequenceDiagram
actor T as Teacher
participant D as HomeworkDetailScreen
participant API as PATCH /api/v1/homework/:id | DELETE
T->>D: Edit → form prefilled (title, description, attachments, dueDate, status)
D->>API: PATCH /homework/{id} (only title, description, attachments, dueDate, status)
API-->>D: 200 updated doc; HomeworkUpdated {homeworkId, changes[]} → in-app (event-queue-map.ts:23)
T->>D: Delete → confirm dialog
D->>API: DELETE /homework/{id}
API-->>D: 200 {message:"OK"}; HomeworkDeleted → audit-write queue (event-queue-map.ts:26)
D-->>T: back to list; removed row (server-confirmed, no optimistic delete)
- Immutable fields:
classId,subjectId,teacherIdare not inUpdateHomeworkDto(homework.dto.ts:35-58) — form disables them after create. - Delete is soft (
base.repository.ts:68-74);GET /homework/class/:classIdwill no longer return it (isDeleted filter). Submissions are not deleted (no cascade, OQ-1/01). - Failure: 404 if already removed (refresh list); offline → blocked.
6. Late submission handling
entry: student submits after dueDate (server allows — OQ-1)
- Server accepts late submissions;
submittedAtis server-set (homework.service.ts:96). - Client derives
isLate = submittedAt > dueDateafter the response and shows atertiarybadge "Late · 2d" on the submission row (no server flag exists — the badge is client-computed,(forward-looking)pending a serverisLatefield). - Grading sheet highlights late rows (
AppBadgewarning) so the teacher can apply policy. - Open question (OQ-1): when the overdue scheduler lands (Homework.md:60), late-rejection or late-marking becomes server-authoritative — client must render whatever the server returns, never block submission client-side.
7. Parent sees pending / grade (read-only)
entry: notification deep link (planned) or child's device — no parent API today (OQ-5)
(planned)GET /homework/class/:classIdread-only +GET /homework/:id/submissionsfor the child's row (parent → student linkage lives in the Students module).- Today: parent sees homework activity only through in-app notifications
(planned — OQ-8); deep linkstudylyon://homework/:id(00-shared/05 §4).
8. Cross-cutting
| Entry | Behavior | Status |
|---|---|---|
Deep link studylyon://homework/:id | opens detail (role-gated actions) | (forward-looking) client |
| Push "homework graded" | opens grade feedback | (planned) push infra (00-shared/12 B3) |
| Notification → homework | HomeworkGraded etc. become in-app notifications | (planned) — enum gap OQ-8 |
Abandonment & exit rules: create form → back = draft prompt; grade sheet → back keeps typed values until dismissed; offline → list from cache + banner; permission denial (role-gated UI) → controls hidden, not errors.
04 — Information Architecture (Homework Module)
- 1. Zone: authenticated app → Homework destination
- 2. Route table (module-owned; extends
00-shared/05 §4) - 3. Screen hierarchy
- 4. Modal hierarchy (module, per
00-shared/05 §5) - 5. Screen relationships
- 6. Quick actions / FAB / context menus
- 7. Information needs per screen (content model)
- 8. Accessibility of IA
Where Homework screens live in the shell (00-shared/05 §2:
/homeworkdestination, roles teacher, student, parent-read). Routes mirror the API exactly:homework.controller.tsexposesPOST /homework,GET /homework/class/:classId,GET /homework/:id,PATCH /homework/:id,DELETE /homework/:id,POST /homework/:id/submit,GET /homework/:id/submissions,PATCH /homework/:id/submissions/:submissionId/grade.
1. Zone: authenticated app → Homework destination
AppShell (/homework branch)
└─ /homework HomeworkList (role-filtered)
├─ /homework/new Create/Edit form (teacher/admin)
├─ /homework/:id Homework detail
│ ├─ submissions tab (teacher/admin) → grading sheet
│ └─ grade feedback (student/parent) → submission detail
└─ /homework/:id/edit Edit form (teacher/admin; PATCH)
- Phone: push navigation; Tablet/desktop (≥ 840 dp): master-detail — list left,
detail right (
00-shared/04 §6).
2. Route table (module-owned; extends 00-shared/05 §4)
| Route | Screen | Auth | Source API |
|---|---|---|---|
/homework | Homework list (class-filtered) | JWT | GET /homework/class/:classId |
/homework/new | Create homework form | JWT (UI-gated to teacher/admin — OQ-9) | POST /homework (+ POST /files/upload) |
/homework/:id | Homework detail + my submission | JWT | GET /homework/:id, GET /homework/:id/submissions |
/homework/:id/edit | Edit form | JWT | PATCH /homework/:id |
/homework/:id/submissions | Submission list (teacher) | JWT | GET /homework/:id/submissions |
/homework/:id/submissions/:submissionId | Grading sheet / grade feedback | JWT | PATCH …/grade; GET /files/:id/download |
/homework/:id/submit | Submission form (student) | JWT | POST /homework/:id/submit (+ POST /files/upload) |
Deep link: studylyon://homework/:id (00-shared/05 §4).
3. Screen hierarchy
flowchart TD
HW[/homework/ list/] --> NEW[/homework/new create/]
HW --> D[/homework/:id detail/]
D --> E[/homework/:id/edit/]
D --> SUB[/homework/:id/submit/]
D --> SL[/homework/:id/submissions teacher/]
SL --> GS[/submissions/:submissionId grade sheet/]
D --> FD[/grade feedback student/]
- List is the hub: creation, detail, and (teacher) submissions all branch from it.
- Detail is role-shaped: student sees Submit + grade feedback; teacher sees Edit/Delete menu + Submissions; parent sees read-only (planned).
4. Modal hierarchy (module, per 00-shared/05 §5)
- Snackbar/toast — "Homework assigned", "Submitted", "Graded", "Copied" (attachment).
- Bottom sheet — attachment preview picker, grade summary quick-view.
- Dialog — delete confirm, regrade confirm ("overwrites existing grade"), submit confirm when late (client-computed flag).
- Full-screen page — create/edit form (> 3 fields → page), grading sheet (list-like, full screen for batch work).
- Loader — inline spinners / skeletons; never full-screen dual column.
5. Screen relationships
- List → detail → edit returns → detail refreshes (
Refresh). - Detail → submissions → grade sheet returns → submission row shows new grade.
- Student detail → submit success → detail shows "Submitted" state (server doc is the single source of truth).
- Any homework state change on server (graded elsewhere) → next screen open re-fetches;
WS
(planned)topic for homework not in00-shared/07 §8list — usenotification.new+ pull-to-refresh.
6. Quick actions / FAB / context menus
- FAB on list: "New homework" (teacher/admin only; one FAB per screen,
00-shared/05 §7). - Context menu on homework row: Edit, Delete (teacher/admin). On submission row (teacher): Open, Grade.
- Swipe (phone): row actions Edit/Delete (≤ 2,
00-shared/08 §1). - Keyboard (desktop):
Nnew homework on list;Esccloses sheets/dialogs.
7. Information needs per screen (content model)
| Screen | Primary data | Secondary |
|---|---|---|
| List | title, dueDate, subjectId, status (homework.schema.ts:18-34) | teacher name (resolved), my-submission badge |
| Detail | full doc + attachments + description | submissions count (teacher) / my submission + grade (student) |
| Grade sheet | submissions: studentId, status, submittedAt, marks?, remarks? (homework-submission.schema.ts:9-31) | attachment preview, late badge (client-computed) |
8. Accessibility of IA
- One
headlineMediumper screen; list rows readtitle, subject, due; grade rows readstudent, status, marks. - Focus order: list → filter → FAB; forms in field order (
08_Form_Specifications.md).
05 — Screen Inventory (Homework Module)
- Legend
- 1. Homework List (teacher: class-filtered)
/homework - 2. Homework List (student: my class)
/homework - 3. Homework Detail
/homework/:id - 4. Create / Edit Homework Form
/homework/new,/homework/:id/edit - 5. Submission Form
/homework/:id/submit(or sheet) - 6. Submissions List (teacher)
/homework/:id/submissions - 7. Grading Sheet
/homework/:id/submissions/:submissionId - 8. Grade Feedback View (student) — within
/homework/:id - 9. Attachment Preview / Download (shared surface)
- Shared components used
- Analytics events (proposed, 00-shared/10 §8)
- Keyboard, landscape, tablet, desktop
Every screen of the Homework module, its intent, route, composition, states, permissions, platform behavior, and events. Authoritative components in 00-shared/03; module components in 07_Component_Library.md. Analytics events follow
{module}.{screen}.{action}(proposed).
Legend
States = idle / loading / success / empty / error(offline, 404, 409, network) / disabled / permission. Role gating is client-side UI policy (server has no homework RBAC — OQ-9).
1. Homework List (teacher: class-filtered) /homework
| Field | Detail |
|---|---|
| Purpose | Browse homework of one class, sorted by due date desc (homework.repository.ts:20) |
| Entry | Homework tab; role-default class preselected; returns to after detail/edit |
| Exit | → detail; → create; → edit; class switch |
| Inputs | Class filter AppDropdown (from Academics module), AppSearchBar (client-side filter; no q param on API) |
| Composition | Class selector header + AppSectionHeader ("Due soon" / "Past due" client groups) + homework cards + AppFAB "New homework" (teacher) |
| States | loading AppSkeleton(list); empty AppEmptyState ("No homework for this class"); error AppErrorState (404 class invalid → "Class not found"); offline cached + banner |
| Source | GET /api/v1/homework/class/:classId → array (non-paginated; no meta — client renders all) |
| Row actions | AppMenu: Edit, Delete (teacher/admin); student: none |
| Analytics | homework.list.open, homework.list.filter_class, homework.list.search |
| Adaptive | phone full-width cards; tablet/desktop 2-col grid + master-detail detail pane |
| a11y | row semantics button, card label = title + subject + due; FAB labeled "New homework" |
2. Homework List (student: my class) /homework
| Field | Detail |
|---|---|
| Purpose | Pending/assigned homework for the student's class |
| Entry | Homework tab (student) |
| Differences from #1 | Class fixed = student profile classId (Students module); no FAB; no edit/delete; card shows my submission state chip (none/submitted/graded+marks) |
| My-state source | GET /homework/:id/submissions filtered client-side by my studentId — per-card fetch only for visible cards (proposed: server flag); OQ-6 |
| States | as #1; plus "Submitted" chip state |
| Analytics | homework.list.open, homework.student.view |
3. Homework Detail /homework/:id
| Field | Detail |
|---|---|
| Purpose | Full homework: title, subject, class, description, attachments, due date, status, assigned date; role-shaped actions |
| Source | GET /homework/:id → 404 RESOURCE_NOT_FOUND "Homework not found." (homework.service.ts:47) |
| Composition | Header (title + subject chip + status badge), meta block (class, teacher, assignedDate, dueDate via AppInfoRow), AppMarkdownViewer description, AppAttachmentList (download/preview), role-actions: teacher → Edit/Delete menu + "Submissions (N)"; student → Submit CTA or submission/grade block; parent → read-only |
| Student block | if no submission: AppButton "Submit"; if submitted: status chip + submittedAt; if graded: marks + remarks + gradedAt |
| States | loading skeleton; 404 empty state ("This homework was removed"); offline banner |
| Analytics | homework.detail.open |
| Adaptive | master-detail right pane ≥ 840 dp |
4. Create / Edit Homework Form /homework/new, /homework/:id/edit
| Field | Detail |
|---|---|
| Purpose | Create (POST /homework) or edit (PATCH /homework/:id) |
| Fields | title (req), description (opt), class (req, create only — immutable after), subject (req, create only), dueDate (req), attachments (opt), status (edit only: active/closed) — exact fields CreateHomeworkDto / UpdateHomeworkDto (homework.dto.ts:4-58) |
| Exit | success → detail; back → draft prompt (create) |
| States | validation per 08_Form_Specifications.md; 400 field errors; 5xx kept-form |
| Analytics | homework.create.submit, homework.create.success, homework.edit.submit |
5. Submission Form /homework/:id/submit (or sheet)
| Field | Detail |
|---|---|
| Purpose | One-time student submission: remarks + optional attachments |
| Source | POST /homework/:id/submit (SubmitHomeworkDto — submission.dto.ts:4-17) |
| Precondition | no existing submission for (studentId, homeworkId) — server 409 guard (homework.service.ts:88-92) |
| Composition | remarks AppTextField (multiline), AppAttachmentUploader (file.upload), Submit button |
| States | uploading (progress per file), submitting (button spinner), success → detail "Submitted"; 409 → "Already submitted" info state (navigates to detail); 404 → removed |
| Analytics | homework.submit.start, homework.submit.success, homework.submit.duplicate |
6. Submissions List (teacher) /homework/:id/submissions
| Field | Detail |
|---|---|
| Purpose | All submissions of one homework for grading |
| Source | GET /homework/:id/submissions → array (non-paginated) |
| Composition | AppSectionHeader "N submitted"; rows: student name, submittedAt, status chip (submitted/graded), marks badge if graded, attachment icons, late badge (client-computed) |
| Row action | tap → grading sheet (graded rows open read-only w/ regrade path) |
| States | loading; empty (AppEmptyState "No submissions yet"); 404 |
| Analytics | homework.submissions.open, homework.submissions.grade_open |
7. Grading Sheet /homework/:id/submissions/:submissionId
| Field | Detail |
|---|---|
| Purpose | Grade one submission: marks + remarks |
| Source | PATCH /homework/:id/submissions/:submissionId/grade (GradeSubmissionDto — submission.dto.ts:19-26) |
| Composition | student header, submittedAt, attachment previews, marks AppTextField (numeric, client-validated ≥ 0 — OQ-4), remarks field, "Grade" button; regrade mode shows existing grade + overwrite warning |
| States | grading (button spinner); 404 "Submission not found."; 400 (client validation); success → row updated + snackbar |
| Analytics | homework.grade.submit, homework.grade.regrade |
8. Grade Feedback View (student) — within /homework/:id
| Field | Detail |
|---|---|
| Purpose | Show marks + teacher remarks to the student |
| Source | from GET /homework/:id/submissions (own row) or the submit response |
| Composition | success-mark card (marks, tabular figures), AppMarkdownViewer remarks, gradedAt/submittedAt, attachments |
| Analytics | homework.feedback.view |
9. Attachment Preview / Download (shared surface)
| Field | Detail |
|---|---|
| Purpose | Preview or download homework/submission attachments |
| Source | GET /files/:id/download → stream with Content-Disposition (files.controller.ts:55-64); requires file.read |
| Behavior | PDF/image → in-app viewer; other types → share sheet/external app; long-press menu (Save, Share) |
| States | downloading (progress), error (404 file removed) |
Shared components used
AppListTile, AppCard, AppButton, AppTextField, AppDropdown, AppDatePicker,
AppMarkdownViewer, AppAttachmentTile, AppBadge, AppChips, AppMenu, AppDialog,
AppBottomSheet, AppSkeleton, AppEmptyState, AppErrorState, AppOfflineBanner,
AppFAB, AppSnackbar, AppSectionHeader, AppInfoRow. Module-specific:
HomeworkCard, SubmissionRow, LateBadge, AttachmentUploader, GradeSheetBody,
SubmissionStatusChip — defined in 07_Component_Library.md.
Analytics events (proposed, 00-shared/10 §8)
homework.list.{open,filter_class,search}, homework.create.{submit,success,failed},
homework.detail.open, homework.edit.{submit,success}, homework.delete.{confirm,success},
homework.submit.{start,success,duplicate,failed}, homework.grade.{open,submit,regrade},
homework.feedback.view, homework.attachment.{preview,download,failed}.
Keyboard, landscape, tablet, desktop
- Forms: keyboard-avoidance + scroll;
Entersubmits single-field steps. - Grade sheet on desktop: full-width split — submissions list left, grade form right.
- List: 2-column at ≥ 600 dp; 3-column ≥ 1200 dp.
06 — Screen Specifications (Homework Module)
- 1. Homework List
/homework - 2. Homework Detail
/homework/:id - 3. Create Homework Form
/homework/new - 4. Submission Form
/homework/:id/submit(sheet or page) - 5. Submissions List (teacher)
/homework/:id/submissions - 6. Grading Sheet
/homework/:id/submissions/:submissionId - 7. Grade Feedback View (student) — detail block (spec in §2)
- 8. Attachment Preview / Share
- 9. Shared micro-interaction & motion notes (all screens)
Production-grade specs for each Homework screen: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens:
Sp= 00-shared/02 §3,Ty= typography §2,El= elevation §5,R= radius §4,Mo= motion §6. All components reference 00-shared/03 and 07 (module).
1. Homework List /homework
Chrome: AppBar "Homework" (titleLarge, El-e1); class filter AppDropdown under
AppBar; body = RefreshIndicator + grouped ListView.builder (group "Due soon" /
"Past due" computed client-side vs dueDate); horizontal padding Sp.16 (phone), Sp.24
(tablet+). Teacher FAB AppFAB.extended "New homework" (Icons.add, heroTag unique).
Row (HomeworkCard, teacher & student):
[Subject chip] [status badge: active/closed] [my-state chip (student)]
Title titleMedium, 2 lines max
Grade 8 - C · Assigned 3d ago bodySmall onSurfaceVariant
Due 15 Aug · in 5d bodyMedium; overdue → tertiary + icon schedule
[attachment icon ×N] bodySmall
- Meta from doc:
title,subjectId(resolved),classId(resolved),dueDate,status(homework.schema.ts:18-34),assignedDate,attachments.length. - Late semantics (OQ-1): server never flags late. Client computes
isLate = dueDate.isBefore(now)— card showstertiarybadge "Overdue · 2d" +scheduleicon (color + icon + text, never color-only —09 §9). Once the server adds enforcement, render server truth only. - States: skeleton (
AppSkeleton(list)); emptyAppEmptyState"No homework yet" + FAB; errorAppErrorState(404 "Class not found" if class invalid, 5xx + requestId); offlineAppOfflineBanner+ cached list (TTL 5 min,06 §3.3). - Pull-to-refresh: re-fetch + WS resubscribe
(planned). - Row menu (teacher): Edit, Delete; delete →
AppDialogconfirm ("Delete this homework? Submissions are kept on the server." — soft delete, no cascade,homework.service.ts:70-81) →DELETE /homework/:idserver-confirmed, row fade-outMo.m-base, snackbar "Deleted". - A11y: card = one
Semantics(button)label"{title}, {subject}, due {date}"; status badges appended; focus order filter → list → FAB. - Dark/light: token-only; overdue badge
tertiaryContainer(light) /tertiary-tinted container (dark) with icon+label. - Tablet/desktop: master-detail at ≥ 840 dp (list left 360–400 dp, detail right);
hover tint on cards;
Nshortcut = new homework.
2. Homework Detail /homework/:id
Chrome: AppBar title "Homework" + trailing AppMenu (teacher: Edit / Delete);
scrollable SingleChildScrollView, sections spaced Sp.24.
Header card (surfaceContainerLow, r-lg, e-1)
title headlineMedium (Ty)
chips: subject · class [status badge] [my-state chip (student)]
Info card
AppInfoRow: Teacher (resolved), Assigned (assignedDate), Due (dueDate; overdue styled)
AppInfoRow: Attachments (N) → tap opens attachment sheet
Description AppMarkdownViewer (data = description; sanitized links)
Actions (role-gated):
student, no submission → FilledButton "Submit" (fullWidth 48h)
student, submitted → SubmissionStatusChip + submittedAt + (graded? marks card)
teacher → OutlinedButton "Submissions (N)" → /submissions
parent → read-only, no actions
States: loading skeleton; 404 → AppEmptyState "This homework was removed or is not
available." (matches server NotFoundException, homework.service.ts:47); offline →
banner + cached detail (00-shared/06 §3.3 — detail views cached last-good).
Grade feedback block (student, when my submission status='graded'):
Card (secondaryContainer tint) "Grade"
marks displaySmall mono tabular · badge "Graded"
remarks → AppMarkdownViewer
gradedAt bodySmall ("Graded 2d ago")
marks is Number? (homework-submission.schema.ts:30-31) — absent until graded;
render "Pending grade" AppBadge when status='submitted' (schema enum
['submitted','graded'], homework-submission.schema.ts:24).
Deep link studylyon://homework/:id → this screen; notification tap → same.
3. Create Homework Form /homework/new
Chrome: full-screen page (> 3 fields rule, 00-shared/05 §5); AppBar "New homework"
SaveTextButton; body form listSp.16gaps,resizeToAvoidBottomInset: true.
1 class AppDropdown (Academics classes; required; onChanged → subject options filter)
2 subject AppDropdown (Subjects; required)
3 title AppTextField (required; maxLength 200 client; autofocus)
4 description AppTextField multiline 4-6 lines; note "Markdown supported" (AppMarkdownViewer
renders it — 00-shared/03 E)
5 dueDate AppDatePicker (required; client min = today; locale-aware; time optional 23:59)
6 attachments AppAttachmentUploader (multi; upload via POST /files/upload per file)
Submit flow: button "Assign homework" → loading spinner; server 201 doc returned
(homework.service.ts:27-43); success → detail + snackbar "Homework assigned".
Validation errors (400 VALIDATION_ERROR): field-level inline, focus first invalid
(submission.dto/homework.dto decorators: IsMongoId, IsString, IsDateString,
IsOptional — homework.dto.ts:4-33).
Attachment UX: each file uploads immediately (id → chip); failures retryable per
file; homework is only created after all attachments uploaded (separate requests —
create must not fire with pending uploads).
4. Submission Form /homework/:id/submit (sheet or page)
Chrome: page (or full-height sheet on phone) with AppBar "Submit homework".
Student identity (from profile; sent as studentId in body — client fills, hidden)
remarks AppTextField multiline "Add a note to your teacher" (optional; server
@IsOptional @IsString — submission.dto.ts:13-16)
attachments AppAttachmentUploader (optional; same file.upload flow)
[Submit button] FilledButton fullWidth "Submit homework"
States:
submitting→ button spinner, inputs disabled, anti-double-submit (00-shared/08 §6).- Success 201 → replace with success view (check icon
success, "Submitted on {submittedAt}") → button "View homework" → detail.submittedAtcomes from the server doc (homework.service.ts:96). - 409
DUPLICATE_RESOURCE"Already submitted." (homework.service.ts:92) → NOT an error: navigate to detail with "Already submitted"AppBanner(info)— the student may have submitted from another device. - 404 → detail empty state ("removed").
- 400 → field errors. 5xx →
AppErrorState+ form state preserved.
Late-submit moment (OQ-1): when now > dueDate, show inline AppBanner(warning)
"Due date has passed — your teacher may accept it" but do not block (server accepts;
blocking client-side would contradict the server contract).
Double-submit safety: submit is not optimistic (00-shared/07 §9 write-once op);
the button locks after first tap; the 409 handler covers retried requests
(Idempotency-Key header supported pattern — 00-shared/07 §9).
5. Submissions List (teacher) /homework/:id/submissions
Chrome: AppBar "Submissions · {title}"; summary line bodySmall "N submitted · M
graded"; RefreshIndicator + ListView.builder.
Row (SubmissionRow):
[avatar initial] Student name titleMedium
Submitted 3d ago · Late badge? bodySmall
[status chip] submitted → tertiary-tinted "Pending"; graded → success "Graded"
[marks badge] mono "17" (graded only)
[attachment icons ×N]
- Late badge: client-computed
submittedAt > dueDate→AppBadge(tertiary, icon schedule)"Late · 2d" (AppBadge+ icon + text — a11y09 §9). Semantics: "Submitted late by 2 days". - Grouping: "Ungraded" first (status='submitted'), then "Graded"
(status='graded') — no server sort (
findByHomeworkreturns insertion order,homework-submission.repository.ts:20-24); client groups. Proposed: server sort bysubmittedAt. - States: empty
AppEmptyState"No submissions yet" + "Share the homework link" (proposed); 404 → detail-removed empty state. - Row tap: ungraded → grading sheet; graded → read-only feedback w/ "Regrade" action.
6. Grading Sheet /homework/:id/submissions/:submissionId
Chrome: page; AppBar "Grade · {student}"; body scrollable.
Student header avatar + name + submittedAt (+ late badge)
Attachment preview AppAttachmentList (download via GET /files/:id/download)
marks AppTextField numeric (label "Marks"; hint "e.g. 20")
client rules: required, ≥ 0, ≤ 100 default (configurable per org —
server has NO bounds, OQ-4: submission.dto.ts:20-21)
remarks AppTextField multiline "Feedback for the student" (optional,
@IsOptional @IsString — submission.dto.ts:24-26)
[Grade button] FilledButton fullWidth "Save grade"
Regrade mode (existing status='graded'): show current marks + remarks; button
"Save new grade" + AppBanner(warning) "This overwrites the previous grade"
(server overwrites unconditionally and emits HomeworkGraded again — homework.service.ts:127-143, OQ-2).
States: grading (button spinner, no double-submit); success → row state updates via
pop result + snackbar "Grade saved"; 404 "Submission not found." (homework.service.ts:126)
→ back + refresh; 400 → field errors.
Optimistic vs server-confirmed (see 13): grading is server-confirmed — the
updated doc (marks, remarks, status:'graded', gradedAt) is written only on 200.
No local paint-before-write (side effects: event → notifications).
Batch flow (proposed): after saving a grade, "Next ungraded" chip appears → advances
to the next status='submitted' row (client-side; list already loaded).
7. Grade Feedback View (student) — detail block (spec in §2)
Reuses GradeFeedbackCard component; read-only; marks in mono tabular figures.
8. Attachment Preview / Share
- PDF/image preview in app (
PageView+ zoom,00-shared/08 §1double-tap zoom); other types → share sheet / external viewer. - Download:
GET /files/:id/download→ buffer → save/share; error 404 → snackbar "File no longer available". - Upload (from create/submit forms):
AppAttachmentUploader— per-file states{idle, uploading(progress), uploaded(fileId), failed(retry), removed}; retry = re-POST same file (new upload each attempt — no resume;00-shared/12 B7).
9. Shared micro-interaction & motion notes (all screens)
- Route transitions
Mo.m-base; cardsEl-e1; FABMo.m-entrancescale, hide on scroll-down (00-shared/03 D). - Snackbars: success < 3 s, error ≥ 4 s.
- Status chip change (
submitted→graded) animatesAnimatedSwitcherMo.m-fast. - Keyboard: numeric
TextInputType.numberfor marks; multiline for remarks;Ctrl+Entersubmits on desktop (00-shared/08 §2). - Safe areas: bottom padding under FAB/lists; sheets respect
MediaQuery. - Reduced motion: fades + instant only, no shake/stagger.
07 — Component Library (Homework Module)
- A. Homework representation
- B. Attachments
- C. Grading surface
- D. Status & feedback
- E. Only elements the API supports
- F. Flutter widget map (module)
- G. Golden tests (base 00-shared/03 G)
Module-specific components built from shared
App*primitives (00-shared/03). Props, states, animation, a11y, tokens, Flutter mapping. Reuse over new — only components that don't exist in the shared library are defined here.
A. Homework representation
HomeworkCard
- Purpose: one homework row in the list (teacher + student variants).
- Props:
title,subjectLabel,classLabel,dueDate,assignedDate,status(active/closed,homework.schema.ts:33-34),attachmentCount,myState?(none|submitted|graded|overdue),overdue,onTap,menu?(teacher: edit/delete). - Rendering:
AppCardflat (e-0) +ListTile-like layout; subjectAppChip(readonly); statusAppBadge(active →secondaryContainer, closed →outline); my-stateSubmissionStatusChip; due linebodyMedium(onSurfaceVariant), overdue →tertiary+scheduleicon; attachment count iconattachment. - States: default / selected (master-detail) / disabled (deleted in flight).
- A11y: single
Semantics(button)— "Algebra Worksheet, Mathematics, due 15 Aug, not submitted"; menu separately labeled. - Tokens:
surfaceContainerLow,R-md,El-e0/e1. - Flutter:
Card+InkWell+Row/Column; date viaDateTime.toDisplayDate(locale)(00-shared/11 §8).
SubmissionRow
- Purpose: one submission row in the teacher's submission list.
- Props:
studentName,submittedAt,status(submitted/graded),marks?,remarks?,attachmentCount,isLate(client-computed),onTap,onGrade?. - Rendering:
AppAvatar(initials) leading; title = student name; subtitle = "Submitted {relative} · {attachmentCount} attachments"; trailing =AppBadgestatus (submitted →tertiaryContainer"Pending"; graded →secondaryContainer"Graded") + marksAppBadge(mono); late →LateBadge. - A11y: label "Student name, submitted 3 days ago, pending, late by 2 days".
- Flutter:
ListTileinAppCarde-0.
LateBadge (AppBadge variant)
- Purpose:
submittedAt > dueDateindicator (OQ-1 — server has no flag; client-derived). - Props:
lateBy(Duration),compact?. - Rendering:
tertiarytext +scheduleicon 16, chiptertiaryContainer; text "Late · 2d". - A11y: always icon + text (never color-only, 09 §9); semantics "submitted late by 2 days".
- Flutter:
Badge/customContainer+Icon.
SubmissionStatusChip
- Purpose: student-facing submission state on homework cards/detail.
- Props:
state(none|submitted|graded|overdue),marks?. - Mapping: none →
AppBadge(outline)"Not submitted"; submitted →AppBadge(tertiaryContainer)"Submitted"; graded →AppBadge(secondaryContainer)"Graded · 17/20"; overdue →AppBadge(tertiary)"Overdue". - Flutter:
Chipwithavataricon.
GradeFeedbackCard
- Purpose: student sees marks + remarks after grading.
- Props:
marks?,remarks?,gradedAt?,submittedAt,late. - Rendering:
AppCardtonal (secondaryContainer); marksdisplaySmallmonotabular figures + "/{max}" config; remarksAppMarkdownViewer; footer "Graded {relative} · Submitted {relative}". - A11y: label "Grade 17 out of 20. Feedback: {remarks}".
- Flutter:
Card.filled+Row.
B. Attachments
AppAttachmentUploader
- Purpose: multi-file upload inside create/submit forms; drives
POST /files/upload(files.controller.ts:29-41). - Props:
files[],onAdd,onRetry,onRemove,maxCount?(client default 5),maxSizeMB?(client default 25 — no server limit exists, OQ-4/01; enforce client-side),acceptTypes?(client hint; server accepts anything). - Per-file state machine:
idle → uploading(progress 0..1) → uploaded(fileId) | failed(reason); removal allowed in idle/uploaded/failed. - Rendering:
AppAttachmentTile(name, size, type, uploading %, retry, remove) + "Add attachment"OutlinedButton. - A11y: progress announced (
liveRegion"Uploading {name}, 45%"); remove labeled. - Flutter:
ListTilerows +LinearProgressIndicatorper file;file_picker-style picker (verify license; wrap for swappability).
AppAttachmentList
- Purpose: read-only attachment display (homework detail, submission detail).
- Props:
items[{id, name, size, mimeType}],onPreview,onDownload. - Behavior: tap → preview (PDF/image in-app) or share sheet; long-press menu (Save, Share).
- Source: file metadata comes from the
filescollection (file.schema.ts:8-43), fetched by id; download viaGET /files/:id/download(files.controller.ts:55-64). - Flutter:
AppAttachmentTilelist.
C. Grading surface
GradeSheetBody
- Purpose: the marks+remarks form body of the grading sheet (reused for regrade).
- Props:
initialMarks?,initialRemarks?,maxMarks(client default 100),regrade(bool),onSubmit(marks, remarks),submitting. - Validation: required, numeric,
0 ≤ marks ≤ maxMarks(server has no bounds —submission.dto.ts:20-21, OQ-4); remarks ≤ 2000 chars (client). - Rendering:
AppTextFieldnumeric +AppTextFieldmultiline + regradeAppBanner. - Flutter:
Form+TextFormFieldvalidators.
D. Status & feedback
- Reuses
AppBanner(info: "Already submitted"; warning: late, regrade),AppSnackbar,AppErrorState,AppEmptyState,AppOfflineBanner,AppSkeletonfrom 00-shared/03 with no module changes.
E. Only elements the API supports
- Homework/submission lists are non-paginated arrays (
findByClass,findByHomeworkreturn full arrays —homework.repository.ts:17-21,homework-submission.repository.ts:20-24) → no infinite scroll; render all. Pagination(planned)when endpoints growpage/limit. - No "my submissions" bulk endpoint (OQ-6) → detail screen fetches
GET /homework/:id/submissionsfor the single homework and filters bystudentId.
F. Flutter widget map (module)
| Component | Flutter widgets |
|---|---|
| HomeworkCard | Card + InkWell + Chips + Badge |
| SubmissionRow | ListTile + Badge + MenuAnchor |
| LateBadge | Container + Icon + Text |
| SubmissionStatusChip | Chip |
| GradeFeedbackCard | Card.filled + AppMarkdownViewer |
| AppAttachmentUploader | Row of ListTile + LinearProgressIndicator |
| AppAttachmentList | ListView of AppAttachmentTile |
| GradeSheetBody | Form + TextFormField |
G. Golden tests (base 00-shared/03 G)
Every module component golden at phone/tablet/desktop + dark; required state goldens:
HomeworkCard (submitted/graded/overdue/closed), LateBadge, AppAttachmentUploader
(uploading/error/complete), GradeSheetBody (empty/invalid/regrade).
08 — Form Specifications (Homework Module)
- 1. Create Homework Form —
POST /api/v1/homework(homework.dto.ts:4-33) - 2. Edit Homework Form —
PATCH /api/v1/homework/:id(homework.dto.ts:35-58) - 3. Submission Form —
POST /api/v1/homework/:id/submit(submission.dto.ts:4-17) - 4. Grade Form —
PATCH /api/v1/homework/:id/submissions/:submissionId/grade(submission.dto.ts:19-26) - Form-level rules (all)
- Client-side error priority (all forms)
Every form field-by-field. Validation mirrors class-validator decorators exactly from
src/modules/homework/dto/*.ts; messages follow theVALIDATION_ERROR(400) envelope with per-fielddetails(http-exception.filter.ts:27-35,103-108). Client validates inline then submits; server 400 shadows client.
1. Create Homework Form — POST /api/v1/homework (homework.dto.ts:4-33)
| # | Field | Label | Keyboard | Validation (server) | Client UX |
|---|---|---|---|---|---|
| 1 | classId | Class | — | @IsMongoId() required | AppDropdown from Academics; required; immutable after create |
| 2 | subjectId | Subject | — | @IsMongoId() required | AppDropdown (filtered by class where known); immutable after create |
| 3 | teacherId | Teacher | — | @IsMongoId() required | hidden — filled from profile (server trusts body; OQ-9 note in 01) |
| 4 | title | Title | text | @IsString() required | maxLength 200 (client), autofocus, TextInputAction.next |
| 5 | description | Description | multiline | @IsOptional() @IsString() | 4–6 line field; "Markdown supported" helper (rendered via AppMarkdownViewer) |
| 6 | dueDate | Due date | — | @IsDateString() required | AppDatePicker; client min = today; format locale-aware; default time 23:59 local |
| 7 | attachments | Attachments | — | @IsOptional() (string[]) | AppAttachmentUploader (uploads happen before submit; ids sent) |
Server 400 mapping: class-validator errors arrive as
error: { code: 'VALIDATION_ERROR', details: [{ message }] } — map to fields by
trailing field name heuristics where details[].message lacks field ids
(http-exception.filter.ts:103-108); always also show the banner message.
Submit: button "Assign homework" → loading → 201 → detail + snackbar.
Errors: 5xx → AppErrorState keeping the form; offline → blocked, banner.
2. Edit Homework Form — PATCH /api/v1/homework/:id (homework.dto.ts:35-58)
| # | Field | Required | Server validation | Client UX |
|---|---|---|---|---|
| 1 | title | no | @IsOptional() @IsString() | prefilled; empty string sent → validation ok, doc updated |
| 2 | description | no | @IsOptional() @IsString() | prefilled |
| 3 | attachments | no | @IsOptional() | list diff; add/remove before PATCH (full replace — server $sets whole array, homework.service.ts:57) |
| 4 | dueDate | no | @IsOptional() @IsDateString() | date picker; client min = today (moving a due date to the past is possible server-side; flag with helper text) |
| 5 | status | no | @IsOptional() @IsString() | AppDropdown active/closed (schema enum homework.schema.ts:33; invalid string → 400 enum cast error → treat as validation) |
- Immutable:
classId,subjectId,teacherIdare not in the DTO — fields disabled in the edit form with note "Class/subject cannot be changed after assignment". - Submit sends only changed fields (
changes[]echoed inHomeworkUpdatedevent payload,homework.service.ts:65); success → detail refresh. - 404 → empty state; 400 → field errors.
3. Submission Form — POST /api/v1/homework/:id/submit (submission.dto.ts:4-17)
| # | Field | Label | Keyboard | Validation (server) | Client UX |
|---|---|---|---|---|---|
| 1 | studentId | Student | — | @IsMongoId() required | hidden — from profile; the only identity the server trusts (no JWT-sub binding, OQ-9/01) |
| 2 | remarks | Note to teacher | multiline | @IsOptional() @IsString() | optional; placeholder "Add a note (optional)"; maxLength 2000 (client) |
| 3 | attachments | Attachments | — | @IsOptional() (string[]) | AppAttachmentUploader; at least one of remarks/attachments not required by server — allow empty submission (server accepts; client keeps a confirm) |
Flow: submit → button spinner → 201 submission doc → success view → detail. Errors:
- 409
DUPLICATE_RESOURCE"Already submitted." (homework.service.ts:92) → navigate to detail +AppBanner(info)(not an error state). - 404 → homework removed.
- 400 → field errors; 5xx → keep form.
- Late due date: inline
AppBanner(warning)"The due date has passed" — submission still allowed (server accepts; OQ-1).
4. Grade Form — PATCH /api/v1/homework/:id/submissions/:submissionId/grade (submission.dto.ts:19-26)
| # | Field | Label | Keyboard | Validation (server) | Client |
|---|---|---|---|---|---|
| 1 | marks | Marks | number | none — no decorator (submission.dto.ts:20-21) | required, numeric, 0 ≤ marks ≤ maxMarks (client default 100, org-configurable); hint "Out of {max}" |
| 2 | remarks | Feedback | multiline | @IsOptional() @IsString() | optional; maxLength 2000 (client) |
- Server will accept negative/absurd marks (OQ-4) — client is the only guard today; document server-side limit addition as pending.
- Regrade: if
status='graded'already, show existing values +AppBanner(warning)"This overwrites the previous grade" + button "Save new grade" (server overwrites and re-emitsHomeworkGraded,homework.service.ts:127-143). - Success → snackbar "Grade saved" + row updates from server doc
(
marks, remarks, status:'graded', gradedAt). - Errors: 404 "Submission not found." (
homework.service.ts:126) → back+refresh; 400 → field errors; 5xx → keep typed values + retry.
Form-level rules (all)
- Double-submit: disabled while pending (00-shared/08 §6).
- Optimistic: no optimistic writes for create/submit/grade — each is server-confirmed; the submission 409 handler is the only "local success" path (00-shared/07 §9).
- Undo: delete homework → snackbar "Deleted" with no UNDO (soft-delete is irreversible from the client; there is no restore endpoint) — confirm dialog instead (00-shared/05 §5). Attachment removal inside a form = UNDO via snackbar (4 s).
- Abandonment: create/edit forms preserve drafts locally (
(proposed)shared_preferences); back from create → "Discard draft?" dialog. - Keyboard:
.nextsequence, last.done;Ctrl/Cmd+Entersubmits on desktop. - Error copy: from
messageof envelope for business 4xx only; codes for the rest (00-shared/07 §11).
Client-side error priority (all forms)
- 400 VALIDATION → field.
- 404 RESOURCE_NOT_FOUND → empty state / refresh.
- 409 DUPLICATE_RESOURCE → info state (submission) / inline (create race).
- 429 RATE_LIMITED → countdown (uploads count toward
api100/min tier, 00-shared/07 §4). - 5xx → AppErrorState + requestId.
09 — User Behaviour (Homework Module)
- 1. Expected behaviour (server-contract-driven)
- 2. Common mistakes & countermeasures
- 3. Power & new users
- 4. Mobile & a11y users
- 5. Cross-device & conflict
- 6. Interrupted sessions & retries
- 7. Abandonment
- 8. Frustration points → UX treatments (summary)
- 9. Adoption path (holistic)
Expected behaviour (derived from server semantics + shared interaction rules), the common mistakes users make with these screens, and the UX countermeasures.
1. Expected behaviour (server-contract-driven)
- Submission is exactly-once. The server refuses a second submission with 409
"Already submitted." (
homework.service.ts:88-92) and the schema enforces it with a unique index (homework-submission.schema.ts:37-40). The UI must treat "already submitted" as done, not as an error — a student who submits from two devices, or double-taps, must land on an informative state. - Late submission is accepted. No due-date check exists in
submit()(homework.service.ts:83-112) — the UI must never hard-block a submit, only inform (OQ-1). The late badge is client-computed until the server provides a flag. - Grading overwrites. Re-grading replaces marks/remarks and resets
gradedAt(homework.service.ts:127-134). Teachers should be warned before overwriting; students should see the newest grade only. - Homework deletion hides the homework (soft-delete,
base.repository.ts:68-74) but keeps submissions — after delete, the homework detail 404s; teachers should be told submissions are retained server-side. - Update is partial. Only
title/description/attachments/dueDate/statuschange (homework.dto.ts:35-58) — a teacher who expects to move homework to another class will fail; the UI disables those fields. - Lists are class-scoped and due-date-sorted (
homework.repository.ts:19-20) — the "due soon" grouping the UI shows matches the sort. - No role enforcement on the server (OQ-9): any JWT can create/grade. The client gates by role; when perms land, gates stay.
2. Common mistakes & countermeasures
| Mistake | Related | UX countermeasure |
|---|---|---|
| Double-tap "Submit" → 409 | submit | disable button on first tap; map 409 → "Already submitted" info banner, never red error |
| Submitting from two devices | submit | the 409 path above; submission state always re-read from server on screen open |
| Teacher grades the wrong student | grade | grade sheet shows student avatar+name header and the homework title; require explicit row selection |
| Typing marks like "20/25" | grade | numeric-only keyboard + input formatter (digits only); "Out of {max}" hint |
| Setting a due date in the past | create | date picker min = today; edit form warns if past date selected |
| Expecting to move homework to another class | edit | fields disabled + helper "Class/subject cannot be changed" |
| Deleting homework thinking it vanishes submissions | delete | confirm dialog copy: "Students' submissions are kept." |
| Losing typed grade on network error | grade | form state preserved on 5xx; retry offered; never navigate away on failure |
| Upload stuck mid-flight (network loss) | attachments | per-file failed state + retry; snackbar guidance; no silent corruption (10 §2) |
| Closing app mid-upload | attachments | pending uploads shown on return (proposed); uploads not resumed (B7) |
3. Power & new users
- Power teacher: keyboard
N(new homework), batch grading flow "Next ungraded"(proposed); pastes marks lists (tab-separated → client fills rows,(proposed)). - New student: first run — "Homework" tab shows empty state + explanation "Assignments from your teachers will appear here".
4. Mobile & a11y users
- One-thumb: primary CTA full-width bottom-ish; FAB reachable.
- TalkBack/VoiceOver: full submit + grade flows walkable; upload progress announced; late badge read with text, not color.
- Dynamic type 200%: cards reflow,
maxLinesonly on labels. - Reduced motion: no shake/stagger; status changes = fade.
5. Cross-device & conflict
- Student submits on phone; teacher grades on web — student's next open of the detail shows the grade (no WS homework topic yet; re-fetch on focus + pull-to-refresh).
- Two teachers grade the same submission concurrently: last write wins (server
updateById$set,homework.service.ts:127); the second grader's sheet shows the current grade on load (re-fetch before save if stale — comparegradedAt).
6. Interrupted sessions & retries
- App killed mid-submit: on restart, the detail re-fetches; if the submission landed,
the 409 path shows "Already submitted"; if not, the form restores draft
(proposed). - Upload interrupted: per-file retry (new upload); no resume (B7).
- 429 (uploads/rapid actions): countdown, no auto-retry (00-shared/07 §4).
- Session expiry mid-flow:
sessionExpiredoverlay → login → return to the same screen (state preserved where safe, 10 §3).
7. Abandonment
- Create form abandoned → draft kept locally (client memo,
(proposed)); "Discard?" prompt on back with content. - Submit form abandoned → draft (remarks + picked files) kept; badge on the card
"Draft"
(proposed). - Grade sheet abandoned mid-entry → typed values kept until sheet closed deliberately.
8. Frustration points → UX treatments (summary)
| Frustration | Treatment |
|---|---|
| "Already submitted" shown as red error | Re-map to info banner + disable submit |
| Can't tell if my homework is late | client-computed LateBadge with icon+text |
| No "my homework" list (OQ-6) | resolve classId from profile; cache it (TTL 24 h) |
| Marks has no server bounds (OQ-4) | client validation + "Out of {max}" hint; flag server fix |
| Grading overwrites silently (OQ-2) | regrade confirm + overwrite banner |
| Homework deleted → submissions kept (no cascade) | explain in delete confirm; teacher can still see submissions? (no — list 404s with homework; document in OQ) |
9. Adoption path (holistic)
P0 (client): list → detail → create → submit → grade → feedback, all server-confirmed.
P1: late badges, regrade UX, upload progress polish, drafts. P2 (planned):
GET /homework student endpoint (PLAN.md:67), parent read view
(IMPLEMENTATION_PLAN.md:227), bulk assign, plagiarism check, homework emails
(IMPLEMENTATION_PLAN.md Phase 4), WS homework topic, push notifications
(00-shared/12 B3).
10 — Interaction Specification (Homework Module)
- 1. Phone / touch
- 2. Keyboard / desktop / mouse
- 3. Haptics (mobile)
- 4. Motion language (module extension of 08 §4)
- 5. Micro-interactions catalogue
- 6. Frustration controls (shared 08 §6 + module)
- 7. Special: deep-link & clipboard interactions
- 8. Validation of interaction to server semantics
Module-specific interactions layered on shared 00-shared/08. Covers every input modality on phone/tablet/desktop for list, forms, grading, attachments.
1. Phone / touch
| Gesture | Behaviour | Where |
|---|---|---|
| Tap | open card, submit, grade, preview attachment | all |
| Tap on submit/grade CTA | submitting-aware disabled re-tap | submit/grade forms |
| Long-press | AppMenu on homework rows (Edit/Delete); attachment menu (Save/Share) | lists, attachments |
| Double-tap | zoom in attachment preview (PDF/image) | preview only (08 §1) |
| Swipe left | homework row quick actions (Edit, Delete) — ≤ 2 (08 §1) | teacher list |
| Pull down | refresh list / submissions (RefreshIndicator) | all scrollables |
| Hardware back | pop form/sheet → previous screen; form with content → discard prompt | all |
| Edge swipe | shell drawer | app shell |
2. Keyboard / desktop / mouse
| Input | Behaviour |
|---|---|
| Tab | logical form order (class → subject → title → description → dueDate → attachments → CTA); focus ring primary |
| Enter | submits single-field contexts; Ctrl+Enter submits forms from text areas (remarks/description) |
| Esc | closes sheet/dialog/menu; cancels selection; focus returns to trigger |
| N | new homework (list screen, teacher) |
| Ctrl/Cmd+K | global search (shell) |
| Arrows | dropdown navigation (class/subject/status) |
| Right-click | context menu = long-press menu |
| Hover | m-fast tint on cards/rows; tooltip after 500 ms |
| Focus | visible ring everywhere; logical traversal (09 §6) |
3. Haptics (mobile)
| Event | Haptic |
|---|---|
| Submit success | lightImpact |
| Grade saved | lightImpact |
| 409 duplicate (already submitted) | selectionClick (informational) |
| Delete confirm | heavyImpact |
| Upload complete (per file) | selectionClick |
| Upload failed | mediumImpact |
| Rule | off when system haptics off; none on desktop (08 §3) |
4. Motion language (module extension of 08 §4)
| Transition | Duration/curve | Used by |
|---|---|---|
| List → detail | m-base slide (+ Hero on card title m-slow) | detail open |
| Form open | m-base slide-up | create/edit/submit |
| Status chip change | AnimatedSwitcher fade m-fast | submitted→graded |
| Row removal after delete | m-base fade/slide | list |
| Card entrance | m-entrance stagger 40 ms | list load |
| Upload progress bar | determinate m-instant updates | attachment uploader |
| Late badge appear | m-fast pop | computed on load |
Reduced-motion: fades + instant only; no stagger, no hero, no shake.
5. Micro-interactions catalogue
| Interaction | Detail |
|---|---|
| Submit pressed | button loading swap m-base, double-tap locked, haptic on success |
| 409 already-submitted | form swaps to success-ish state + info banner; no red flash |
| Grade saved | snackbar "Grade saved" + row re-animates (marks badge pop m-fast) |
| Attachment uploaded | chip icon morph file→check m-fast; progress 100% → selectionClick |
| Attachment failed | red ring + failed chip + Retry button; error persists until acted |
| Delete homework | confirm dialog → server confirm → row fade-out; snackbar "Homework deleted" (no undo — no restore endpoint) |
| Regrade confirm | warning banner inside sheet + dialog if a previous grade exists |
6. Frustration controls (shared 08 §6 + module)
- All presses respond < 100 ms with
m-fastripple. - Every network action shows pending state (button spinner, per-file progress, skeleton).
- Submit/grade buttons disabled while pending — anti-double-submit.
- Uploads never fail silently: per-file error + retry.
- Errors persist until corrected/dismissed (persistent banner for 409/regrade warnings).
- No auto-retry on 429; countdown shown.
7. Special: deep-link & clipboard interactions
studylyon://homework/:id→ detail; notification tap (graded/submitted) → detail.- Attachment preview: copy text-selection in PDF; share via platform sheet.
- Marks entry: paste a number is accepted; formatter strips non-digits.
8. Validation of interaction to server semantics
- Never block submit client-side on
dueDate(server accepts late — OQ-1). - Never allow a second submission in UI (409 is the boundary; UI treats it as done).
- Grade feedback always rendered from the server doc (
marks?may be absent whilestatus='submitted'—homework-submission.schema.ts:24,30-31). - Delete = soft delete; the row must disappear from lists (isDeleted filter,
base.repository.ts:20-30) but the client must not assume the file records are gone. - No optimistic mutations anywhere in the module (create/submit/grade/delete all server-confirmed — 00-shared/07 §9).
11 — Design System Mapping (Homework Module)
- 1. Theme recipes (module)
- 2. Widget → token
- 3. Typography mapping
- 4. Iconography
- 5. Motion tokens (values 00-shared/02 §6)
- 6. Contrast & dark parity
- 7. Breakpoint behaviour
- 8. Theming rules (enforced)
Homework module → Material 3 / Flutter / token mapping, extending 00-shared/04. Every color, type, spacing, elevation, radius, icon, motion reference traces to 00-shared/02_Design_Tokens.md.
1. Theme recipes (module)
| Surface | Theme | Exception |
|---|---|---|
| Lists & cards | surface / surfaceContainerLow, e-0/e-1 | selected (master-detail) → primaryContainer |
| Status chips | secondaryContainer (active/graded), tertiaryContainer (pending/late), outline (closed/not submitted) | — |
| Grade feedback card | Card.filled secondaryContainer | marks in onSecondaryContainer |
| Warning surfaces (late, regrade, past due) | tertiaryContainer | icon + text, never color-only |
| Destructive (delete/regrade confirm) | error / errorContainer | AppDialog destructive action |
2. Widget → token
| Component | M3 / Flutter | Tokens | Elev | Radius |
|---|---|---|---|---|
| HomeworkCard | Card | surfaceContainerLow | e-0/e-1 | r-md |
| Subject chip | Chip | surfaceVariant | — | r-sm |
| Status badge active | Badge | secondaryContainer | — | r-sm |
| Status badge closed | Badge | outline | — | r-sm |
| LateBadge | Badge + icon | tertiaryContainer / tertiary | — | r-sm |
| SubmissionRow | Card + ListTile | surfaceContainerLow | e-0 | r-md |
| GradeFeedbackCard | Card.filled | secondaryContainer | e-1 | r-lg |
| Create/Edit/Submit form fields | TextFormField | fill surfaceVariant, border outline, focus primary | — | r-md |
| Primary CTA (submit/grade) | FilledButton | primary/onPrimary | — | r-md |
| Secondary (submissions, add attachment) | OutlinedButton | outline/onSurface | — | r-md |
| FAB "New homework" | FloatingActionButton.extended | primaryContainer | e-3 | r-full |
| Delete/regrade confirm | AppDialog | surfaceContainerHigh, destructive error | e-4 | r-lg |
| Attachment upload tile | ListTile + LinearProgressIndicator | surfaceVariant track, primary progress | — | r-md |
| Snackbar | SnackBar | inverseSurface | e-5 | r-md |
| Offline banner | AppOfflineBanner | tertiaryContainer | e-1 | — |
3. Typography mapping
| Token | Screen element |
|---|---|
headlineMedium | screen titles (Homework list, detail header) |
titleLarge | AppBar titles, create-form header |
titleMedium | card titles, submission row names, section headers |
bodyLarge | homework description fallback (pre-markdown) |
bodyMedium | meta lines (class, teacher, due), form labels |
bodySmall | timestamps, "Submitted 3d ago", helper text |
labelLarge | buttons, chips |
labelMedium | badges ("Late · 2d", "Graded") |
displaySmall | marks in GradeFeedbackCard |
mono | marks, attachment sizes (tabularFigures()) |
4. Iconography
Material Symbols outlined: assignment, assignment_turned_in, schedule,
event, attach_file, upload_file, download, description, picture_as_pdf,
edit, delete, check_circle, warning, error, refresh, grade, person,
school. Status icons use filled variants (submitted check, graded star).
5. Motion tokens (values 00-shared/02 §6)
| Purpose | Token | Applied to |
|---|---|---|
| List→detail transition | m-base | navigation |
| Hero on card→detail title | m-slow | detail open |
| Status chip morph | m-fast | submitted→graded |
| Upload progress updates | m-instant | determinate bars |
| Card entrance stagger | m-entrance (40 ms) | list load |
| Badge pop (marks appear) | m-fast | grade result |
| Row removal (delete) | m-base | list |
Rules: reduced-motion substitutes fades/instants (08 §4).
6. Contrast & dark parity
- Body text ≥ 4.5:1; interactive chrome ≥ 3:1 (both modes).
- Dark:
secondaryContainerdark variant (chromatic roles TBD by designer, 02 §1.4); late badgewarningtext onwarningContainerverified for contrast. - No sole-color status: every badge pairs icon + text (09 §9).
- Marks read as text, not color (
tabularFigures).
7. Breakpoint behaviour
| Class | List | Detail/Forms |
|---|---|---|
| phone <600 | single column, FAB, swipe actions | push navigation, full-screen forms |
| 600–839 | 2-col grid | master-detail |
| ≥1200 | 3-col grid or list+detail panes | grade sheet splits: submissions list left, form right |
8. Theming rules (enforced)
- No literal
Color/padding/duration in widget code — tokens only (00-shared/04 §7). - Tenant branding overrides
primaryonly; badges derive from roles, not branding. - Dark mode is a first-class theme (badges re-tinted, contrast re-verified).
12 — API Mapping (Homework Module)
- 0. Module-wide request envelope & client policy
- Screen: Homework list (teacher/student) —
GET /homework/class/:classId - Screen: Homework detail —
GET /homework/:id - Screen: Create homework —
POST /homework - Screen: Edit homework —
PATCH /homework/:id - Screen: Delete homework —
DELETE /homework/:id - Screen: Submit homework —
POST /homework/:id/submit - Screen: Submissions list (teacher) —
GET /homework/:id/submissions - Screen: Grade submission —
PATCH /homework/:id/submissions/:submissionId/grade - Screen: Attachments — FilesModule
- Loading / streaming / realtime
- Client-side error mapping table (module)
- Optimistic / undo
- Notifications surface (for the notification badge/detail)
Exact wire contract for every screen → endpoint. Base
/api/v1; envelope per 00-shared/07. All endpoints fromsrc/modules/homework/homework.controller.ts; business rules fromhomework.service.ts; attachments fromfiles.controller.ts. Guard:@UseGuards(JwtAuthGuard)(homework.controller.ts:19) — no RBAC metadata (OQ-9). Tenant from JWT only; never in body (tenant is injected byBaseRepository,base.repository.ts:24-29).
0. Module-wide request envelope & client policy
| Aspect | Contract |
|---|---|
| Base | https://api.<domain>/api/v1 |
| Headers | Authorization: Bearer <accessToken>; x-request-id; Content-Type: application/json; multipart for /files/upload |
| Response | {success, message:"OK", data, meta?, timestamp, requestId} (response-envelope.interceptor.ts:49-52) |
| Error | {success:false, message, error:{code, details?}, timestamp, requestId} (http-exception.filter.ts:73-81) |
| Pagination | homework/submissions are non-paginated arrays (no meta) — render all |
| Caching | client list cache TTL 5 min; detail no cache (00-shared/06 §3.3) |
| Offline | reads cached; writes blocked (create/submit/grade) except attachment upload retry |
| Retry | backoff on 5xx/network; no auto-retry on 429 (api tier 100/min) |
Screen: Homework list (teacher/student) — GET /homework/class/:classId
| Endpoint | GET /api/v1/homework/class/:classId |
| Params | classId (MongoId) |
| Success | 200 data: [HomeworkDoc…] sorted dueDate desc (homework.repository.ts:20) |
| Source | homework.controller.ts:25-27 |
| Errors | 404/400 invalid id (CastError → 400 VALIDATION_ERROR, http-exception.filter.ts:47-55); 5xx |
| Student variant | client resolves own classId from profile (PLAN.md:67 endpoint missing — OQ-6) |
| Pagination | none (full array) |
HomeworkDoc shape (homework.schema.ts:8-35 + BaseSchema):
_id, tenantId, teacherId, classId, subjectId, title, description?, attachments[], assignedDate, dueDate, status: 'active'|'closed', createdAt, updatedAt, isDeleted…
Screen: Homework detail — GET /homework/:id
| Endpoint | GET /api/v1/homework/:id |
| Success | 200 data: HomeworkDoc |
| Errors | 404 RESOURCE_NOT_FOUND "Homework not found." (homework.service.ts:47) |
Screen: Create homework — POST /homework
| Endpoint | POST /api/v1/homework (homework.controller.ts:22-24) |
| Body | {teacherId, classId, subjectId, title, description?, attachments?: string[], dueDate} (homework.dto.ts:4-33; dueDate ISO date string) |
| Success | 201 data: HomeworkDoc — server sets assignedDate: now, status: 'active' (homework.service.ts:30-32) |
| Side effect | emits HomeworkCreated {homeworkId, classId} → in-app queue (homework.service.ts:34-41, event-queue-map.ts:22) |
| Errors | 400 VALIDATION_ERROR (decorators: IsMongoId, IsString, IsDateString); 5xx |
Screen: Edit homework — PATCH /homework/:id
| Endpoint | PATCH /api/v1/homework/:id (homework.controller.ts:31-36) |
| Body | any subset of {title, description, attachments, dueDate, status} (homework.dto.ts:35-58) — classId/subjectId/teacherId immutable |
| Success | 200 data: HomeworkDoc ($set, version+1 via updateById, base.repository.ts:57-66) |
| Side effect | emits HomeworkUpdated {homeworkId, changes[]} → in-app (homework.service.ts:59-67, event-queue-map.ts:23) |
| Errors | 404 "Homework not found." (homework.service.ts:56-58); 400 invalid status enum value |
Screen: Delete homework — DELETE /homework/:id
| Endpoint | DELETE /api/v1/homework/:id (homework.controller.ts:37-39) |
| Success | 200 {message:"OK"} — soft delete (homework.service.ts:70-81, base.repository.ts:68-74); submissions retained (no cascade) |
| Side effect | emits HomeworkDeleted {homeworkId} → audit-write queue (event-queue-map.ts:26) |
| Errors | 404 "Homework not found." (homework.service.ts:71-72) |
Screen: Submit homework — POST /homework/:id/submit
| Endpoint | POST /api/v1/homework/:id/submit (homework.controller.ts:40-45) |
| Body | {studentId, remarks?, attachments?: string[]} (submission.dto.ts:4-17) |
| Success | 201 data: SubmissionDoc — server sets submittedAt: now, status: 'submitted' (homework.service.ts:93-98) |
| Side effect | emits HomeworkSubmitted {homeworkId, studentId, submissionId} → in-app (homework.service.ts:99-110, event-queue-map.ts:24) |
| Errors | 404 "Homework not found." (homework.service.ts:87); 409 DUPLICATE_RESOURCE "Already submitted." (homework.service.ts:92) + unique index (homework-submission.schema.ts:37-40); 400 |
| Note | No due-date enforcement — late accepted (OQ-1) |
SubmissionDoc shape (homework-submission.schema.ts:8-32):
_id, tenantId, homeworkId, studentId, attachments[], remarks?, submittedAt, status: 'submitted'|'graded', gradedAt?, marks?
Screen: Submissions list (teacher) — GET /homework/:id/submissions
| Endpoint | GET /api/v1/homework/:id/submissions (homework.controller.ts:46-48) |
| Success | 200 data: [SubmissionDoc…] insertion order (homework-submission.repository.ts:20-24) |
| Errors | 400 invalid id; 5xx |
Screen: Grade submission — PATCH /homework/:id/submissions/:submissionId/grade
| Endpoint | PATCH /api/v1/homework/:id/submissions/:submissionId/grade (homework.controller.ts:49-55) |
| Body | {marks, remarks?} (submission.dto.ts:19-26) — marks has no validation decorator (OQ-4) |
| Success | 200 data: SubmissionDoc — sets marks, remarks, status:'graded', gradedAt: now (homework.service.ts:127-134) |
| Side effect | emits HomeworkGraded {homeworkId, submissionId, marks} → in-app (homework.service.ts:136-143, event-queue-map.ts:25) |
| Errors | 404 "Submission not found." (homework.service.ts:126); 400 |
| Regrade | repeatable — overwrites + re-emits (OQ-2) |
Screen: Attachments — FilesModule
| Upload | POST /api/v1/files/upload multipart file field, @Permissions('file.upload') (files.controller.ts:29-41); returns FileRecord doc (file.schema.ts:8-43) incl. _id → put id in attachments[] |
| Download | GET /api/v1/files/:id/download file.read → stream (files.controller.ts:55-64) |
| Meta | GET /api/v1/files/:id file.read (files.controller.ts:49-53) |
| Delete | DELETE /api/v1/files/:id file.delete (files.controller.ts:66-71) — not called by homework flows (attachment removal = homework PATCH replacing the array) |
| Limits | none enforced server-side (size/mime) — client enforces (OQ-4/01) |
Loading / streaming / realtime
| Screen | Loading | Streaming | Realtime |
|---|---|---|---|
| list | AppSkeleton | — | (planned) WS topic; re-fetch on focus |
| detail | skeleton | — | re-fetch on focus (grade may have landed) |
| create/edit | button spinner | — | — |
| submit | button spinner + per-file upload progress | upload stream | — |
| grade | button spinner | — | — |
Client-side error mapping table (module)
| Screen | code | UI |
|---|---|---|
| submit | 409 DUPLICATE_RESOURCE | "Already submitted" info state (not error) |
| any detail | 404 RESOURCE_NOT_FOUND | empty state "removed" |
| grade | 404 | back + refresh list |
| create/edit | 400 VALIDATION_ERROR | field-level errors |
| any | 401 → refresh → fail | sessionExpired |
| any | 429 RATE_LIMITED | countdown, no retry |
| any | 5xx | generic + requestId, retry |
Optimistic / undo
- No optimistic mutations — create/submit/grade/delete are all server-confirmed (side effects: events + notifications; 00-shared/07 §9).
- Undo: only in-form attachment removal (local). Delete has confirm dialog, no undo (soft-delete but no restore endpoint).
Notifications surface (for the notification badge/detail)
GET /api/v1/notifications, GET /notifications/unread-count,
PATCH /notifications/:id/read, PATCH /notifications/read-all
(notifications.controller.ts:21-47). Homework event types are routed
(event-queue-map.ts:22-25) but fail enum validation today (notification.schema.ts:7-12,
inapp.worker.ts:46-53) — OQ-8; badge counts homework activity once the enum is extended.
13 — State Management (Homework Module)
- 1. HomeworkListCubit (list screen, both roles)
- 2. HomeworkDetailCubit
- 3. HomeworkFormCubit (create + edit)
- 4. SubmitCubit (submission form)
- 5. SubmissionsCubit (teacher list)
- 6. GradeCubit (grading sheet)
- 7. Attachment upload (shared mixin)
- 8. Realtime & cross-cutting interplay
- 9. Testing hooks (
00-shared/06 §6)
Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) backed by
HomeworkRepository(dio) calling the endpoints in 12_API_Mapping.md. Module-wide rules: no optimistic mutations, server-confirmed writes only; submission is a write-once op with a 409 handler; grading is optimistic-free (side effects: events → notifications).
1. HomeworkListCubit (list screen, both roles)
stateDiagram-v2
[*] --> initial
initial --> loading : Load(role)
loading --> loaded : class homework array (dueDate desc)
loading --> error : 5xx / 404
loaded --> loading : Refresh (pull) | ChangeClass(filter)
loaded --> empty : 0 items
error --> loading : Retry
loaded --> loaded : Delete done (row removed server-confirmed)
- State:
{status, items[], classId, role}. items=HomeworkSummary[](doc → model:id, teacherId, classId, subjectId, title, description, attachments, assignedDate, dueDate, status).- Student variant:
classIdfromStudentProfileCubit(Students module, cached 24 h); emitsmyStateper card lazily via per-cardGET /homework/:id/submissionsfilter by ownstudentId(proposed)— OQ-6. - Caching: list cache key
hw:list:{tenant}:{classId}, TTL 5 min, stale-while-revalidate (00-shared/06 §3.3);RefreshIndicatorbypasses. - Events:
Load,Refresh,ChangeClass(classId),Retry,Delete(id)→{status}. Delete is server-confirmed; on 200 remove fromitems.
2. HomeworkDetailCubit
- State:
{status, homework?, mySubmission? (student), submissionsCount? (teacher)}. - Load:
GET /homework/:id→ doc; teacher additionally fetchesGET /homework/:id/submissions(count). Student fetches submissions and filters own row (studentIdfrom profile). - Events:
Load(id),Refresh,EditDone(re-fetch after PATCH),SubmitDone(replacemySubmissionwith server doc). - Late flag derived:
submittedAt > dueDate(client-computed; OQ-1). - No cache (volatile); re-fetch on focus (grade may have landed elsewhere).
3. HomeworkFormCubit (create + edit)
- State:
{mode, form{title, description, classId, subjectId, dueDate, status?}, attachmentUploads: Map<fileKey, UploadState>, status: idle|submitting|error(field?) }. UploadState:{idle, uploading(progress), uploaded(fileId), failed}— one per file; onlyuploadedids enterattachments[]on submit.- Create submit →
POST /homework→ success → clear draft → navigate detail. - Edit submit →
PATCH /homework/:id→ refresh detail. - Errors: 400 →
fieldErrors(mapdetails[].message→ field); 5xx →error(form preserved). Draft persisted locally on abandon(proposed). - No optimistic writes anywhere in this cubit.
4. SubmitCubit (submission form)
stateDiagram-v2
[*] --> idle
idle --> uploading : pick files
uploading --> ready : all uploaded
uploading --> failed : file error (retry per file)
ready --> submitting : Submit
submitting --> done(submittedDoc) : 201
submitting --> duplicate : 409 "Already submitted."
submitting --> error : 5xx / network
duplicate --> done : treat as submitted (fetch doc)
- Events:
AddFile,RetryFile,RemoveFile,Submit(remarks, attachments). - Key decision:
duplicateis a terminal success-like state — navigate to detail with info banner (homework.service.ts:92semantics). - Late-warning computed from
homework.dueDatewhennow > dueDate(banner, never blocks). - No optimistic write;
submittedAtfrom server doc (homework.service.ts:96).
5. SubmissionsCubit (teacher list)
- State:
{status, items[], homeworkTitle, grouped{ungraded[], graded[]}}. - Load:
GET /homework/:id/submissions→ map docs (studentId, submittedAt, status, marks?, remarks?, attachments, gradedAt?); client groups ungraded (status='submitted') / graded (status='graded') and computesisLate. - Events:
Load,Refresh,GradeDone(submissionId, updatedDoc)→ replace item + regroup. - No pagination (array).
6. GradeCubit (grading sheet)
- State:
{status: idle|grading|done|error, current: SubmissionGradeModel?, form{marks, remarks}, isRegrade}. isRegrade = current.status == 'graded'(schema enumhomework-submission.schema.ts:24).- Submit →
PATCH …/grade→ 200 doc →done→ pop with result →SubmissionsCubitapplies (GradeDone). - Optimistic vs server-confirmed: grading is server-confirmed by design — the
row's
marks/status/gradedAtcome only from the 200 payload; no local paint-before- write (eventHomeworkGradedhas side effects: notifications). Rationale:00-shared/07 §9write-with-side-effects rule. - Errors: 404 →
gone(pop + refresh); 400 → field; 5xx → keep form.
7. Attachment upload (shared mixin)
stateDiagram-v2
[*] --> idle
idle --> uploading : POST /files/upload (multipart, progress)
uploading --> uploaded : 201 FileRecord → fileId
uploading --> failed : network / 4xx / 5xx
failed --> uploading : Retry (new upload, no resume — B7)
uploaded --> [*] : referenced in attachments[]
- Progress via dio
onSendProgress;uploadingcount gates the Submit/Assign button (submitEnabled = no uploading && no failed).
8. Realtime & cross-cutting interplay
- WS: no homework topic in
00-shared/07 §8— subscribe tonotification.new(planned); on reconnect re-fetch current screen (00-shared/06 §3.4). ConnectivityCubit: offline → list from cache + banner; submit/grade/create blocked with guidance (no offline write queue defined for homework writes — only uploads retry).AuthCubitsession expiry mid-flow → re-login → screen re-loads (state preserved where safe).
9. Testing hooks (00-shared/06 §6)
- Unit:
SubmitCubit409→duplicate transition;GradeCubitregrade branch;LateBadgederivation (timezone-safe); upload state machine. - Widget: list loading/error/empty; submit states incl. duplicate banner; grade sheet error keeps values; regrade warning visible.
14 — QA Checklist (Homework Module)
- 1. Functional (server-contract mirrors)
- 2. Visual / layout
- 3. Accessibility (00-shared/09)
- 4. Performance
- 5. Offline & network (00-shared/10 §2)
- 6. Tablet / desktop
- 7. Localization
- 8. Permissions
- 9. Dark mode
- 10. Animations
- 11. Security
- 12. Server-backed edge mirrors
- QA scripts
Module QA specific to framework + shared 00-shared/10. Every check derives from a specific backend behavior so failure analysis is direct.
1. Functional (server-contract mirrors)
-
Create homework with all fields → 201 doc with
status:'active',assignedDateset (homework.service.ts:30-32); e2e baselinetest/p1-operations.e2e-spec.ts:285-301. -
Create without
title/dueDate/non-MongoId refs → 400VALIDATION_ERROR(homework.dto.ts:4-33). -
Class list returns array sorted
dueDatedesc (homework.repository.ts:20). -
Detail: known id → 200; unknown → 404 "Homework not found." (
homework.service.ts:47). -
Update: only
title/description/attachments/dueDate/statusaccepted;classId/subjectId/teacherIdin PATCH body are ignored (not in DTO,homework.dto.ts:35-58) — verify no crash. -
Delete → 200; the doc disappears from class list (soft delete +
isDeletedfilter,base.repository.ts:68-74); submissions remain in DB (no cascade). -
Due-date/timezone: create with
dueDateISO incl. timezone offset; list shows correct local date. Submit after dueDate → 201 (server accepts — OQ-1); client late badge shows. Submit before due → no late badge. -
Duplicate submission: second
POST /homework/:id/submitfor same (studentId, homeworkId) → 409DUPLICATE_RESOURCE"Already submitted." (homework.service.ts:92); also covered by unique index (homework-submission.schema.ts:37-40) — verify direct DB insert is rejected. - Resubmission rules: no resubmit path exists — after 409 the UI must show the info state, never allow editing the submission (OQ-1 note).
-
Regrading: grade twice → 200 both times; second call overwrites marks,
resets
gradedAt, emits secondHomeworkGraded(homework.service.ts:127-143); UI shows regrade warning. -
Grade unknown submission → 404 "Submission not found." (
homework.service.ts:126). -
Marks edge: server accepts
-5and9999(no decorator,submission.dto.ts:20-21— OQ-4); client must reject/limit; verify client is the only guard today. -
status:'closed'homework: submit and grade still succeed server-side (no enforcement — OQ-3); UI communicates closure but doesn't claim blocking. -
Attachment limits:
POST /files/uploadhas no size/mime limit in code (files.controller.ts:29-41) — client enforces (max 25 MB, doc types); verify oversized file → client-side rejection message. -
Attachment download returns stream with correct
Content-Disposition(files.controller.ts:55-64); deleted file → 404 → snackbar. -
In-app notification for homework events:
event-queue-map.ts:22-25routes exist; known gap:NotificationTypeenum lacks the event types (notification.schema.ts:7-12) → notification create fails validation (inapp.worker.ts:46-53). QA: assert queue job lands; mark notification persistence as(planned)until enum fix (OQ-8).
2. Visual / layout
- Cards/badges at 200% text scale no clipping (title maxLines 2, meta wraps).
- Dark-light parity: status chips, late badge, grade card, upload progress.
-
Marks in
monotabular figures align in lists. -
Long titles + long file names wrap gracefully;
SelectableTexton detail.
3. Accessibility (00-shared/09)
- TalkBack: list → detail → submit flow; grade flow; attachment upload announced ("Uploading Algebra.pdf, 45%").
- VoiceOver separately; NVDA on web/desktop.
-
Keyboard-only: create form tab order,
Ctrl+Entersubmit,Esccloses sheets. - Late badge readable as text, not color-only.
- Errors announced on focus; focus to first invalid field on submit.
- Contrast 4.5:1 body / 3:1 chrome both modes.
4. Performance
- List first frame < 300 ms cached; network content ≤ 2 s (00-shared/10 §1).
-
No duplicate parallel requests: detail (teacher) fetches homework + submissions
in one
Future.wait; student per-card submissions fetch coalesced(proposed: single endpoint). -
Upload of 25 MB file doesn't jank UI (progress updates throttled,
m-instant).
5. Offline & network (00-shared/10 §2)
- List renders cached + offline banner; pull-to-refresh blocked gracefully.
- Submit/grade/create offline → blocked with guidance; draft preserved.
- Network loss mid-upload → per-file pending/retry, no silent corruption.
- Network loss after submit request sent but before response → on retry, 409 path shows "Already submitted" (never duplicate server records).
- 429 (rapid actions/uploads) → countdown, no auto-retry.
6. Tablet / desktop
- Master-detail list+detail ≥ 840 dp; grade sheet split view ≥ 1200 dp.
- Hover states on cards/rows; right-click menu == long-press menu.
-
Nshortcut (new homework) on list;Tabtraversal in grade sheet.
7. Localization
-
All strings via i18n keys (
homework.*); date/time viaIntllocale; due-date formatting respects org locale/timezone. - Long German/Hindi/Spanish smoke strings don't break badges/cards.
8. Permissions
-
Homework endpoints currently JWT-only (
homework.controller.ts:19) — verify cross-role calls succeed server-side; client role-gates UI (OQ-9). -
file.upload/file.readrequired on files endpoints (files.controller.ts:30,44): a JWT without these perms gets 403PERMISSION_DENIED→ upload disabled in UI. -
When
homework.*perms land (blueprint Homework.md:66-71), re-run matrix: create/update/delete/submit/grade gating.
9. Dark mode
- Full visual pass: badges, grade card, upload tiles, dialogs; contrast re-checked.
10. Animations
- Tokens only; reduced-motion = fades/instants; no shake on duplicate-409.
11. Security
-
No homework/student data in logs/analytics beyond event payloads (already
minimal: ids only,
homework.service.ts:40,65,79,105-109,142). - Uploads never auto-preview executable types; download opens via share sheet.
-
studentIdin submit body is client-filled — flag: server does not bind it to the JWT subject (OQ-9) → verify product decision (student can submit on behalf of any id).
12. Server-backed edge mirrors
- 404 after homework deleted → empty state "removed" (detail + submissions).
-
CastError on malformed id → 400
VALIDATION_ERROR"Invalid resource identifier." (http-exception.filter.ts:47-55,91-92). -
Double-tap submit → single server record + one
HomeworkSubmittedjob (queue idempotencyqueue-bridge.service.ts:44-46).
QA scripts
- Golden per component (light/dark × phone/tablet/desktop × key states).
- E2E (device cloud P0): teacher creates w/ attachment → student submits (with file) → teacher grades → student sees grade → duplicate submit shows info state.
- Upload matrix: 0 B file, 25 MB boundary, wrong mime, mid-flight network kill, retry.
- Notification pipeline: enqueue
HomeworkGraded→ assert in-app job; monitor enum gap (OQ-8).
15 — Flutter Implementation Guide (Homework Module)
- 1. Folder structure
- 2. Dependencies
- 3. Cubits
- 4. HomeworkRepository
- 5. Navigation
- 6. Upload progress UI (
AppAttachmentUploader) - 7. Markdown description rendering
- 8. Late badge derivation (timezone-safe)
- 9. Theme
- 10. Localization keys
- 11. Testing
- 12. Performance
- 13. Proposals flagged to the team
How to build the Homework feature in the Flutter client on top of 00-shared/11. Forward-looking spec; no client repo exists yet.
1. Folder structure
features/homework/
├── domain/
│ ├── models/
│ │ ├── homework.dart # id, teacherId, classId, subjectId, title,
│ │ │ # description?, attachments[], assignedDate,
│ │ │ # dueDate, status (HomeStatus enum)
│ │ ├── homework_submission.dart # id, homeworkId, studentId, attachments[],
│ │ │ # remarks?, submittedAt, status (SubStatus enum),
│ │ │ # gradedAt?, marks? + derived isLate
│ │ └── homework_file.dart # id, originalName, mimeType, size (files doc)
│ └── exceptions/homework_exceptions.dart # AlreadySubmittedException (409)
├── data/
│ ├── dto/
│ │ ├── create_homework_dto.dart
│ │ ├── update_homework_dto.dart
│ │ ├── submit_homework_dto.dart
│ │ └── grade_submission_dto.dart
│ └── repositories/
│ ├── homework_repository.dart
│ └── file_repository.dart # upload/download (shared with other modules)
└── presentation/
├── cubit/
│ ├── homework_list_cubit.dart
│ ├── homework_detail_cubit.dart
│ ├── homework_form_cubit.dart
│ ├── submit_cubit.dart
│ ├── submissions_cubit.dart
│ └── grade_cubit.dart
├── pages/
│ ├── homework_list_page.dart
│ ├── homework_detail_page.dart
│ ├── homework_form_page.dart # create + edit (mode flag)
│ ├── submit_page.dart
│ ├── submissions_page.dart
│ └── grade_sheet_page.dart
└── widgets/
├── homework_card.dart
├── submission_row.dart
├── late_badge.dart
├── submission_status_chip.dart
├── grade_feedback_card.dart
├── attachment_uploader.dart
├── attachment_list.dart
└── grade_sheet_body.dart
2. Dependencies
flutter_bloc, dio (AppDio — 120 s timeout on uploads, 00-shared/11 §5),
go_router, get_it, intl, flutter_markdown (description + remarks rendering,
sanitize links — 00-shared/03 E), cached_network_image (avatar only; file previews
download-then-render), file_picker/image_picker (pick attachments; verify
license — wrap in AttachmentPicker so swappable), path_provider (temp previews).
No PDF/office viewer lib in P0 — external/share-sheet fallback (verify later).
3. Cubits
Per 13_State_Management.md: HomeworkListCubit, HomeworkDetailCubit,
HomeworkFormCubit, SubmitCubit, SubmissionsCubit, GradeCubit. All pure-Dart,
repositories injected; UI never calls dio directly.
4. HomeworkRepository
class HomeworkRepository {
Future<List<Homework>> byClass(String classId); // GET /homework/class/:id
Future<Homework> byId(String id); // GET /homework/:id
Future<Homework> create(CreateHomeworkDto); // POST /homework
Future<Homework> update(String id, UpdateHomeworkDto); // PATCH /homework/:id
Future<void> remove(String id); // DELETE /homework/:id
Future<HomeworkSubmission> submit(String id, SubmitHomeworkDto); // POST /homework/:id/submit
// throws AlreadySubmittedException on 409
Future<List<HomeworkSubmission>> submissions(String id); // GET /homework/:id/submissions
Future<HomeworkSubmission> grade(String id, String submissionId,
GradeSubmissionDto); // PATCH …/grade
}
FileRepository.upload(File, onProgress) → HomeworkFile (id) via POST /files/upload
(file.upload perm); download(id) → bytes via GET /files/:id/download (file.read).
DTO mapping per 00-shared/11 §4; envelope parsing in AppDio error interceptor.
5. Navigation
go_router routes (under the /homework shell branch, 00-shared/05 §2):
/homework (list), /homework/new, /homework/:id, /homework/:id/edit,
/homework/:id/submit, /homework/:id/submissions,
/homework/:id/submissions/:submissionId.
Guards: authGuard; role-gated route meta canCreate/canGrade — hidden at
navigation level (FAB, menu) and enforced by permissionGuard('homework.create')
style checks once server perms land (OQ-9).
Deep link: studylyon://homework/:id → detail (root Uri table in
00-shared/11 §6).
6. Upload progress UI (AppAttachmentUploader)
dioonSendProgress→UploadState.uploading(progress)→LinearProgressIndicatordeterminate; throttle widget rebuilds (updates ≤ 60/s viaValueNotifier<double>).- States per file:
idle → uploading → uploaded(fileId) | failed(reason); retry re-POSTs (no resume —00-shared/12 B7). - Gate: Submit/Assign disabled while
uploading > 0 || failed > 0(failed shows inline error + Retry, never silent). - a11y:
Semantics(liveRegion: true, label: "Uploading {name}, {percent}%"). - 25 MB client cap; error copy "File too large (max 25 MB)" (server has no limit — OQ-4/01; update when server adds one).
- Offline mid-upload → file keeps
failed(network); on reconnect auto-retry(proposed).
7. Markdown description rendering
AppMarkdownViewer(data: homework.description)—flutter_markdownwrapped (00-shared/03 E): sanitize links (allow http/https only),selectable: true, compact style for remarks, full style for description; empty → hide block.- Server stores plain text (
description: String?,homework.schema.ts:21-22) — no backend rendering; client-side only.
8. Late badge derivation (timezone-safe)
isLate(DateTime dueDate, DateTime submittedAt)compared in tenant timezone (from org config; default device). Compute at render time, never cache the boolean in the model (due date is server UTC ISO — parse withDateTime.parse(...).toLocal()viaAppDateTimeutil,00-shared/11 §8).- Server has no late flag (OQ-1) — badge is derived UI; when the server adds
isLate/overdue scheduler, prefer server value.
9. Theme
AppTheme.light()/dark() unchanged; module adds no tokens — all surfaces use role
colors (11_Design_System_Mapping.md). AppSpacing/AppRadius/AppMotion constants
only.
10. Localization keys
homework.list.title, homework.list.empty, homework.create.title,
homework.submit.title, homework.submit.already_submitted, homework.grade.title,
homework.grade.regrade_warning, homework.late.badge ("Late · {d}d"),
homework.status.* (active/closed/submitted/graded), homework.attachment.*
(uploading/too_large/retry), error-code fallbacks per 00-shared/07 §11.
11. Testing
- Unit:
SubmitCubit409→duplicate;GradeCubitregrade;lateBadgetimezone (UTC boundary cases); DTO↔model mappers; upload state machine. - Widget: list loading/error/empty; detail role variants (student/teacher); submit duplicate banner; grade sheet error keeps values; uploader states.
- Golden:
HomeworkCard,SubmissionRow,LateBadge,AttachmentUploader,GradeSheetBody(light/dark × 3 sizes). - Integration: teacher create → student submit → grade → feedback; duplicate submit.
- E2E (P0): full loop with real upload on device cloud (
14_QA_Checklist.md).
12. Performance
ListView.builderfor lists;RepaintBoundaryaround markdown/PDF previews.- Grade sheet:
Future.waithomework+submissions on open (single skeleton). - Upload progress via
ValueNotifier, not setState. constconstructors; cache student profile classId.
13. Proposals flagged to the team
- When server adds
GET /homework(PLAN.md:67 student endpoint), drop the per-card submissions hack (OQ-6). - When
homework.*permissions land, wire route/FAB/menu gating to real perms (OQ-9). - When
NotificationTypeenum accepts homework events (OQ-8), enable notification deep-link navigationstudylyon://homework/:id. - When parent view lands (
IMPLEMENTATION_PLAN.md:227), add read-only parent mode. - Analytics wiring waits shared
AnalyticsService(00-shared/10 §8).
01 - Product Overview (Houses Module)
StudyLyon - multi-tenant ERP / School Management API. This package designs the Houses module client (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, permissions and wire contracts are derived directly from
src/modules/houses/**,src/modules/students/**,src/modules/rbac/permissions.constants.ts,docs/IMPLEMENTATION_PLAN.mdand the shared spec indesign-docs/00-shared/. No feature is invented - anything not present in source is flagged(planned)/(proposed)/(forward-looking).
Heads-up: per the PRD, the mobile client is out of Phase 1 scope
(PRODUCT_REQUIREMENTS_DOCUMENT.md:144, flagged in 00-shared/12 A1); this package
is the forward-looking spec the client will be built against later. Houses is one of
the smallest modules in the backend - a single controller/service/repository slice.
1. Purpose
Houses manages the school's house system (e.g. Reddy, Nehru, Tagore): named, color-coded groups students are assigned to for sports, cultural and inter-house competition. The module provides:
- House CRUD - name, code, color, motto (no mascot / house master / points yet).
- Student assignment - a student points at a house via
houseId; there is no house-side assignment endpoint (see §3.2). - Tenant-scoped, soft-deletable documents with a unique
{tenantId, code}index.
| Responsibility | Source |
|---|---|
| House CRUD + soft delete | houses.controller.ts:24-52, houses.service.ts:18-54 |
| Unique code guard (409) | houses.service.ts:19-21; index house.schema.ts:23 |
| Tenant scoping + soft-delete on every query | base.repository.ts (via house.repository.ts:9-15) |
| JWT guard on all endpoints | houses.controller.ts:19-20 |
| Student → house reference | student.schema.ts:41-42 |
| House assignment DTO fields | create-student.dto.ts:39-42, update-student.dto.ts:45-48 |
| RBAC permissions | permissions.constants.ts:46-49 |
2. Business goals
| Goal | Measure |
|---|---|
| No duplicate house codes per tenant | unique {tenantId, code} index + service 409 (house.schema.ts:23, houses.service.ts:19-21) |
| Every house identifiable by color | color field optional (house.schema.ts:15-16) - no uniqueness enforced |
| Cross-tenant isolation | every query tenant-scoped via BaseRepository.scopedFilter |
| Houses survive deletion, not vanish | soft delete only (houses.service.ts:51-54, base.schema.ts:19-27) |
3. User goals
- School / house admin: create and maintain the house roster (name, code, color, motto); keep the palette unambiguous so inter-house UI stays readable.
- Registrar: assign a house to a student at admission (
houseIdincreate-student.dto.ts:39-42) and reassign later (update-student.dto.ts:45-48). - Teacher / student / parent: (forward-looking) see which house a student belongs to and per-house member lists - no dedicated read surface exists in source today.
4. Scope
4.1 In scope (implemented backend)
House CRUD: create (POST /houses), paginated list (GET /houses?page=1&limit=20),
get by id (GET /houses/:id), update (PATCH /houses/:id), soft delete
(DELETE /houses/:id) - houses.controller.ts:24-52. All JWT-guarded, tenant-scoped.
Student-side assignment via POST /students / PATCH /students/:id
(student.controller.ts:38, 56).
4.2 Planned (not in source; absent from IMPLEMENTATION_PLAN.md)
IMPLEMENTATION_PLAN.md contains no houses entries; the following are product
gaps this package flags (planned):
- House member list endpoint /
GET /students?houseId=filter (see 09 §G1). - Delete guard - deleting a house does not check assigned students
(
houses.service.ts:51-54;student.schema.ts:41-42dangling ref). - House mascot, house master (staff lead), points ledger - none in
house.schema.ts:9-19. - Domain events (
HouseCreated,StudentHouseAssigned) - houses service emits none (contraststudent.service.ts:79-91).
4.3 Forward-looking (client roadmap)
Push notifications on house events (inter-house results) and QR-based member
check-in (forward-looking) - no contract exists anywhere in source.
4.4 Proposed (analytics)
Screen analytics events houses.*.* per 00-shared/10 §8 - (proposed).
5. Non-goals (this version)
- House points / inter-house leaderboard (no field or endpoint).
- Bulk import of houses (Phase 3 bulk import,
IMPLEMENTATION_PLAN.md:172). - Membership management inside the houses API - assignment is exclusively a student
concern (
student.schema.ts:41-42). - Color uniqueness enforcement - duplicates are legal at the API level (QA item, 14 §2).
02 - User Personas (Houses Module)
- 1. House Admin (school admin / sports & culture lead)
- 2. Registrar / Admissions Officer
- 3. Teacher / Staff (house tutor, forward-looking)
- 4. Student (forward-looking)
- 5. Parent (forward-looking)
- Persona-role matrix
Who uses the Houses module, what they need and what the backend actually gives them. Personas map to the screens in 05 and the journeys in 03. Roles are illustrative; the only enforcement today is JWT auth (
houses.controller.ts:19-20)
- RBAC guards are not yet wired (
AGENTS.md), even thoughhouses.*permissions exist (permissions.constants.ts:46-49).
1. House Admin (school admin / sports & culture lead)
| Attribute | Detail |
|---|---|
| Goal | Maintain the house roster: name, code, color, motto; keep colors distinct for inter-house UI |
| Frequency | Setup season (start of year), occasional edits |
| Permissions | houses.read/create/update/delete (permissions.constants.ts:46-49) |
| Core screens | House list, house editor (05 §1-3) |
| Frustrations | No mascot / house-master / points fields exist yet - must wait (planned); duplicate-name protection absent (houses.service.ts:19-21 checks only code) |
| Success metric | Full house roster created in one session; no duplicate codes |
2. Registrar / Admissions Officer
| Attribute | Detail |
|---|---|
| Goal | Assign a house to a student at admission; reassign after transfers or house changes |
| Frequency | Daily during admissions |
| Permissions | student.create/update + houses.read (permissions.constants.ts:25-28, 46) |
| Core screens | Student create/update forms with a house picker (student module); house detail read-only (05 §2) |
| Mechanism | houseId field on create-student.dto.ts:39-42 / update-student.dto.ts:45-48; no dedicated assign endpoint |
| Frustrations | No bulk assignment; no per-house member list in the houses API; transfer flow ignores house (student.service.ts:185-192 keeps houseId) |
3. Teacher / Staff (house tutor, forward-looking)
| Attribute | Detail |
|---|---|
| Goal | See which house students belong to; view member rosters; run inter-house events |
| Frequency | Event weeks |
| Permissions | houses.read + student.read |
| Core screens | House detail with members (05 §2) - member list is a client-side join today, (planned) server support (01 §4.2) |
| Frustrations | No points ledger, no house-master field (planned) |
4. Student (forward-looking)
| Attribute | Detail |
|---|---|
| Goal | Know their house, its color/motto; see house announcements and points |
| Frequency | Event weeks |
| Core screens | My-house view (client-only feature, no backend contract yet) |
| Notes | QR check-in and push results (forward-looking), 01 §4.3 |
5. Parent (forward-looking)
| Attribute | Detail |
|---|---|
| Goal | See the child's house and inter-house participation |
| Frequency | Occasional |
| Notes | Read-only; reuses student record houseId (student.schema.ts:41-42) |
Persona-role matrix
| Persona | houses.read | houses.create | houses.update | houses.delete | student assignment |
|---|---|---|---|---|---|
| House admin | ✅ | ✅ | ✅ | ✅ | via student form |
| Registrar | ✅ | ❌ (typical) | ❌ | ❌ | ✅ student.update |
| Teacher | ✅ | ❌ | ❌ | ❌ | ❌ |
| Student / Parent | ✅ (forward-looking read) | ❌ | ❌ | ❌ | ❌ |
Client gates UI on these permissions (permissions.constants.ts:46-49); server-side
RBAC enforcement remains a (planned) audit item (IMPLEMENTATION_PLAN.md:239).
03 - User Journeys (Houses Module)
- Journey 1 - Set up the house system (house admin)
- Journey 2 - Assign houses to new students (registrar)
- Journey 3 - Run inter-house event (teacher, forward-looking)
- Journey 4 - Retire a house (house admin)
- Journey 5 - Find a student's house (parent/student, forward-looking)
- Journey map
End-to-end journeys through the Houses module, mapped to exact endpoints. Each journey lists the screens (05), the API calls (12) and the state flow (13). Anything that requires a capability the backend does not expose is flagged
(planned)/(forward-looking).
Journey 1 - Set up the house system (house admin)
Trigger: New academic year; school wants 4 color-coded houses.
- Open Houses list (05 §1) →
HouseListCubit.load()→GET /houses?page=1&limit=20(houses.controller.ts:30-34). Empty state. - Tap New house → editor sheet (05 §3) → fill name, code, color, motto.
- Submit →
POST /houses(houses.controller.ts:24-28). Duplicate code → 409House code "X" already exists.(houses.service.ts:19-21) shown inline. - Repeat for 3 more houses; list refreshes (natural
createdAtorder - backend applies no sort,houses.service.ts:30). - Edit a color next day →
PATCH /houses/:id(houses.controller.ts:42-46). Note: PATCH reusesCreateHouseDto-nameandcodeare required in the body (create-house.dto.ts:4-11), so the client must send the full set.
Exit criteria: 4 houses visible with distinct color cards.
Journey 2 - Assign houses to new students (registrar)
Trigger: Admission batch arrives; each student needs a house.
- Create student with
houseId→POST /students(student.controller.ts:38); fieldhouseIdoptionalIsMongoId(create-student.dto.ts:39-42). - Verify assignment: open student → house color shown (client resolves the house
via cached list from
GET /houses, 13 §6). - Wrong house? Update student →
PATCH /students/:idwithhouseId(student.controller.ts:56,update-student.dto.ts:45-48).
Exit criteria: student record shows the house; house detail member count changes
after refetch ((planned) server filter - today the client filters the students
list, 09 §G1).
Journey 3 - Run inter-house event (teacher, forward-looking)
Trigger: Sports day; teacher needs house rosters and wants to see points.
- Open house detail (05 §2) →
GET /houses/:id(houses.controller.ts:36-40)- members (client join today,
(planned)GET /houses/:id/members).
- members (client join today,
- See per-house member counts; plan events.
- Points & leaderboard: not available - no field/endpoint
(planned); QR check-in(forward-looking).
Exit criteria: roster correct; gap logged to backend backlog.
Journey 4 - Retire a house (house admin)
Trigger: House discontinued mid-year.
- Open detail → Delete → confirm dialog.
DELETE /houses/:id→ soft delete (houses.service.ts:51-54,base.schema.ts:19-27).- Guard check: backend does NOT check assigned students (no 409) - students keep
a dangling
houseId(student.schema.ts:41-42) and the house vanishes from lists. Client must warn before delete and offer to clearhouseIdon members(planned)server-side cascade; QA coverage in 14 §4.
Exit criteria: house gone from list; admin notified of the reassignment debt.
Journey 5 - Find a student's house (parent/student, forward-looking)
Trigger: Parent wants to see child's house.
- Child profile → house card (from
student.houseId→GET /houses/:id). - Tap → house detail with color, motto, members (read-only).
Exit criteria: correct house shown; no write access.
Journey map
flowchart LR
A[House list] -->|New| B[House editor]
B -->|POST /houses| A
A -->|tap| C[House detail]
C -->|PATCH| B
C -->|DELETE| A
C -->|members planned| D[Member roster]
E[Student form] -->|houseId POST/PATCH /students| C
04 - Information Architecture (Houses Module)
- 1. Navigation tree
- 2. Entity map
- 3. Object model (client)
- 4. Cross-module relations
- 5. Content hierarchy (house detail)
- 6. IA gaps (flagged)
Where Houses sits in the app information architecture. Global IA rules live in 00-shared/05 (Global Information Architecture); this file defines the Houses subtree only. Server truth:
houses.controller.ts:17-20(/housesroot, JWT-guarded,ApiTags('houses')).
1. Navigation tree
Settings / Organization (parent: School setup)
└── Houses (/houses) houses.read
├── House list (/houses)
├── House detail (/houses/:id) members section (planned server-side)
└── House editor (bottom sheet / dialog - NOT a route, per 00-shared/03)
Student module (assignment lives here, not in Houses)
└── Student detail (/students/:id) -> house chip -> House detail (deep link back)
- House create/edit is a bottom sheet (phone) / dialog (tablet), consistent with
00-shared/03- forms are never routes (see15§7). - House detail is the only deep-linkable page:
/houses/:id.
2. Entity map
| Entity | Fields | Source |
|---|---|---|
| House | name*, code*, color?, motto? + BaseSchema (tenantId, createdBy/updatedBy, isDeleted/deletedAt/deletedBy, version, createdAt/updatedAt) | house.schema.ts:9-19, base.schema.ts:8-34 |
| Student (relation only) | houseId? → ref: 'House' (ObjectId, optional) | student.schema.ts:41-42 |
| Pagination meta | page, limit, totalItems, totalPages, hasNext, hasPrevious | pagination-query.dto.ts:32-39 |
3. Object model (client)
class House {
final String id; // Mongo ObjectId string
final String name; // required, trimmed server-side
final String code; // required, trimmed; unique per tenant
final String? color; // free string - semantic token mapping in 11
final String? motto;
final DateTime? createdAt;
final DateTime? updatedAt;
final int version; // optimistic-lock counter (base.schema.ts:30-31)
}
Never send tenantId / isDeleted / version in requests - server-owned
(base.schema.ts:10-11, 20-21, AGENTS.md).
4. Cross-module relations
| Relation | Mechanism | Source |
|---|---|---|
| House ← Student | student.houseId ObjectId ref (no populate in students service) | student.schema.ts:41-42 |
| House → members | None server-side. Client joins GET /students (filtered) with house list; (planned) filter/members endpoint | 09 §G1, 12 §8 |
| House → staff (house master) | None. Field (planned) | 01 §4.2 |
| House → events (points) | None. (planned) / (forward-looking) | 01 §4.2-4.3 |
5. Content hierarchy (house detail)
- Identity block - color banner, name, code chip, motto.
- Members - count + list (client-joined today;
(planned)GET /houses/:id/members). - Meta - createdAt / updatedAt / version (admin-only, small print).
6. IA gaps (flagged)
- Members live under Students IA but are displayed under Houses - needs a server
bridge (
(planned)houseId filter on students list). - No search/filter on the houses list (
q/sortexist inPaginationQueryDtobut houses list ignores them -houses.service.ts:30).
05 - Screen Inventory (Houses Module)
- 1. House List Screen (
/houses) - 2. House Detail Screen (
/houses/:id) - 3. House Editor (bottom sheet / dialog)
- 4. Member assignment surfaces (student module, cross-reference)
- Shared components used
- Analytics events (proposed)
- Keyboard, landscape, tablet, desktop
Every screen of the Houses module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics. States = idle / loading / success / empty / error(offline, conflict) / disabled / permission. Analytics events follow
{module}.{screen}.{action}(proposed; SDK open - 00-shared/10 §8).
1. House List Screen (/houses)
| Field | Detail |
|---|---|
| Purpose | Browse all houses as color-coded cards |
| Entry | Settings → School setup → Houses; deep link /houses |
| Exit | tap card → /houses/:id; "New house" → editor sheet; swipe-to-refresh |
| Source | GET /houses?page=1&limit=20 (houses.controller.ts:30-34) |
| Widgets | AppListPage shell, HouseColorCard grid (2-col phone / 3-col tablet), FAB "New house", AppSkeleton, AppEmptyState, AppOfflineBanner, AppPagination footer |
| States | loading skeletons; empty ("No houses yet - create your first"); error (offline banner + retry); conflict (409 inline toast from create) |
| Row actions | card tap → detail; AppMenu (ellipsis) → Edit / Delete on card (admin) |
| Permission | houses.read list; houses.create FAB; houses.update/delete menu (permissions.constants.ts:46-49) |
| Analytics | houses.list.open, houses.list.load, houses.list.error (proposed) |
| Notes | No server sort - display in createdAt order; color shown from color field, fallback default token (11 §3) |
2. House Detail Screen (/houses/:id)
| Field | Detail |
|---|---|
| Purpose | Full house identity + member roster |
| Entry | house list card tap |
| Exit | back; Edit → editor sheet; Delete → confirm; member tap → student detail (cross-module) |
| Source | GET /houses/:id (houses.controller.ts:36-40); members from students list (client join, (planned) GET /houses/:id/members) |
| Widgets | HouseHeader (color banner + name + code chip + motto), MemberTile list w/ count header, AppListTile, AppMenu (Edit/Delete), AppSkeleton, AppEmptyState ("No members") |
| States | loading; 404 ("House not found" → back + snackbar); members loading/empty/error (independent per-section state, 13 §4) |
| Permission | houses.read; edit/delete menu gated on houses.update/delete |
| Analytics | houses.detail.open, houses.detail.members.load (proposed) |
| Notes | Member section shows server-unsupported join: must render count as "unverified" until (planned) endpoint lands (09 §G1); delete warns about members (14 §4) |
3. House Editor (bottom sheet / dialog)
| Field | Detail |
|---|---|
| Purpose | Create or edit a house (single form, 08) |
| Entry | FAB (create) or Edit menu (update); never a route (00-shared/03) |
| Exit | Save → success → list/detail refresh; Cancel/dismiss → discard (confirm if dirty) |
| Source | POST /houses (houses.controller.ts:24-28) / PATCH /houses/:id (:42-46) |
| Widgets | AppFormField name/code, ColorPickerField, AppTextField motto, AppButton submit, AppBottomSheet/AppDialog |
| States | idle; submitting (button spinner); 409 conflict inline on code; 404 on update (house deleted meanwhile); offline → mutation blocked (00-shared/10 §2) |
| Permission | houses.create / houses.update |
| Analytics | houses.editor.open, houses.editor.submit, houses.editor.conflict (proposed) |
| Notes | PATCH quirk: body DTO is CreateHouseDto - name+code required on edit too (create-house.dto.ts:4-11); client prefills and submits full set (03 J1 step 5) |
4. Member assignment surfaces (student module, cross-reference)
Assignment is done in Students, not Houses (student.schema.ts:41-42):
- Student create form: house picker (optional,
create-student.dto.ts:39-42). - Student edit: house picker →
PATCH /students/:id(update-student.dto.ts:45-48). - No bulk-assign screen exists
(planned)(03 J2 frustration).
Shared components used
AppTextField, AppButton, AppSnackbar, AppCard, AppListTile, AppBottomSheet,
AppDialog, AppMenu, AppSkeleton, AppEmptyState, AppOfflineBanner, AppFAB,
AppChips, AppBadge, AppAvatar, AppPagination, AppListPage.
Module-specific: HouseColorCard, HouseHeader, MemberTile, ColorPickerField -
defined in 07_Component_Library.md.
Analytics events (proposed)
houses.list.{open,load,error}, houses.detail.{open,members_load},
houses.editor.{open,submit,conflict,delete} - all (proposed).
Keyboard, landscape, tablet, desktop
- Editor sheet scrolls with keyboard (
resizeToAvoidBottomInset); color picker wraps on landscape. - Tablet/desktop: master-detail list/detail; editor as dialog; color cards 3+ per row.
- List supports mouse hover highlight; detail members section max-width 720 dp.
06 - Screen Specifications (Houses Module)
- 1. House List Screen (
/houses) - 2. House Detail Screen (
/houses/:id) - 3. House Editor (bottom sheet / dialog)
- 4. Student-side house picker (cross-module, reused by Students forms)
- 5. Permissions summary per screen
Full per-screen specification: layout, states, widgets, data binding, interactions, permissions, a11y and motion. Authoritative patterns in 00-shared/03 (Component Library), 00-shared/08 (Interaction & Motion), 00-shared/09 (Accessibility Baseline). Widgets marked
App*come from 00-shared/03; module widgets from 07_Component_Library.md.
1. House List Screen (/houses)
1.1 Layout (phone)
AppBar: "Houses" [houses.read]
└─ (right) overflow menu? no - empty state only
HouseColorCard grid (2 columns) # HouseColorCard, 07 §1
[color swatch][Name ]
[code chip · motto 1-line]
FAB: "New house" [houses.create]
Tablet/desktop: 3-column grid, max content width 960 dp, master-detail optional.
1.2 Data binding
| Element | Source | Mapping |
|---|---|---|
| List | GET /houses?page=1&limit=20 → data: House[], meta | HouseListCubit (13 §2) |
| Card | House.name, code, color, motto | house.schema.ts:9-19 |
| Pagination | meta.totalItems/totalPages/hasNext/hasPrevious | pagination-query.dto.ts:32-39 |
No server sort (houses.service.ts:30 finds with {}); client renders createdAt
asc (creation order). q/sort query params exist in PaginationQueryDto
(pagination-query.dto.ts:21-29) but the houses list ignores them - no search box.
1.3 States
| State | Render | Trigger |
|---|---|---|
| Initial | skeletons x4 | mount |
| Loading | skeletons (page>1: footer spinner) | page change, pull-to-refresh |
| Success | grid + footer | 200 envelope |
| Empty | AppEmptyState "No houses yet. Create your first house." | data.length == 0 |
| Error | AppOfflineBanner + retry button; stale grid kept | network 401/500/offline |
| Permission | empty + "contact admin" copy (no FAB) | no houses.read/houses.create |
1.4 Interactions
- Tap card →
/houses/:id(push). - FAB → editor sheet (create mode,
05§3). - Pull-to-refresh → refetch page 1.
- Card menu (ellipsis, admin): Edit → editor (update mode); Delete → confirm dialog
(
14§4 warning about members).
1.5 Motion & a11y
- Card appear: stagger
m-fastfade-up (≤4 rows) per00-shared/08. - Color cards: color must never be the only differentiator - name text always
present (WCAG AA contrast for swatch badges,
00-shared/09). - Skeleton < 200 ms perceived; content < 2 s on network (
00-shared/10§1).
2. House Detail Screen (/houses/:id)
2.1 Layout
[ HouseHeader: full-width color banner (house.color → token, 11 §3) ]
Name (displayLarge) code chip [houses.update: menu ▾ Edit/Delete]
Motto (italic, muted)
[ Members section ]
header: "Members" · count badge · refresh icon
MemberTile list (AppAvatar initials, admissionNumber, class label*)
*(class label not in student list payload today - omit, `(planned)`)
[ Meta section (admin) ]
created · updated · version (small, muted)
2.2 Data binding
| Section | Source |
|---|---|
| Identity | GET /houses/:id → HouseDocument (houses.service.ts:36-40; 404 House not found. :38) |
| Members | Client join (planned): GET /students?page&limit (student.controller.ts:41-43) then filter houseId == id client-side. Server filter / members endpoint (planned) (09 §G1) |
| Meta | base.schema.ts:13-34 (createdAt, updatedAt, version) |
2.3 States
| Section | State | Render |
|---|---|---|
| Header | Loading / 404 / Success | skeletons / "House not found" + pop / banner |
| Members | Loading / Empty / Loaded / Error | 3 skeleton tiles / "No members yet" / count + tiles / inline retry |
| Members | Unverified count | if joined client-side, show ~ prefix + tooltip "server-side member count planned" |
| Whole | Offline | banner; detail cached from list when available (13 §6) |
2.4 Interactions
- Edit → editor sheet prefilled (update mode).
- Delete →
AppDialogconfirm; on confirm: warning list (member count), thenDELETE /houses/:id(houses.service.ts:51-54). No 409 expected - backend has no dependency guard; client warns pre-emptively (14§4). - Member tap → student detail (cross-module navigation via
/students/:id). - Pull-to-refresh → refetch house + members.
2.5 a11y & motion
- Banner color: name/motto must pass contrast over arbitrary
color- compute on tokenization (11§3), fall back to dark scrim. - Delete dialog: destructive button styled
error; focus trapped (00-shared/09). - Section transitions
m-basefade; no parallax on color banner (motion budget).
3. House Editor (bottom sheet / dialog)
3.1 Layout (create mode)
Sheet header: "New House" [houses.create]
Form: (08_Form_Specifications.md)
Name AppTextField (required)
Code AppTextField (required, uppercase hint, conflict inline)
Color ColorPickerField (preset swatches + custom hex)
Motto AppTextField (optional, multiline 1-2)
Actions: [Cancel] [Create house] (filled, fullWidth)
Update mode: title "Edit House", button "Save changes", fields prefilled.
3.2 Data binding
| Field | DTO | Rule (source) |
|---|---|---|
| name | CreateHouseDto.name | required IsString (create-house.dto.ts:5-7); trimmed server-side (house.schema.ts:9-10) |
| code | CreateHouseDto.code | required IsString (create-house.dto.ts:9-11); trimmed (house.schema.ts:12-13); unique per tenant (house.schema.ts:23) |
| color | CreateHouseDto.color | optional IsOptional IsString (create-house.dto.ts:13-16; house.schema.ts:15-16) |
| motto | CreateHouseDto.motto | optional IsOptional IsString (create-house.dto.ts:18-21; house.schema.ts:18-19) |
Create → POST /houses (houses.controller.ts:24-28); Update → PATCH /houses/:id
(houses.controller.ts:42-46). PATCH sends the full DTO (name+code required in
body even for edits) - see 03 J1 step 5, 08 §4.
3.3 States
| State | Render |
|---|---|
| Idle | form enabled, button enabled |
| Submitting | button spinner, fields disabled (single-flight) |
| Conflict (409) | inline error under code field, verbatim House code "X" already exists. (houses.service.ts:19-21); focus field |
| Update 404 | snackbar "House not found" + pop sheet; list refetches |
| Offline | form blocked, AppOfflineBanner (00-shared/10 §2) |
3.4 Interactions
- Enter submits (name+code filled).
- Code field: suggest uppercase normalization; do not enforce - server trims only.
- Cancel with dirty form → discard confirm dialog (motion
m-fast). - Color picker: 8 preset swatches (design tokens
11§3) + custom hex with contrast hint against white text.
3.5 Motion & a11y
- Sheet slide-up
m-base; field errors shakem-fast(00-shared/08). - Labels linked to fields; error messages in live regions (
00-shared/09). - Custom hex input validates
#RRGGBB; invalid → field error, not block.
4. Student-side house picker (cross-module, reused by Students forms)
Not a houses screen - the field contract it depends on:
- optional
houseId(IsMongoId) oncreate-student.dto.ts:39-42andupdate-student.dto.ts:45-48. - Picker options from
GET /houses(house list cubit cache,13§6). - "None" option to clear a house (send empty/omit
houseId).
5. Permissions summary per screen
| Screen | Read | Create | Update | Delete |
|---|---|---|---|---|
| House list | houses.read | houses.create (FAB) | - | houses.delete (menu) |
| House detail | houses.read | - | houses.update (menu) | houses.delete (menu) |
| House editor | - | houses.create | houses.update | - |
Source: permissions.constants.ts:46-49. Server RBAC not wired yet (AGENTS.md -
"Auth (JWT/RBAC guards) ... not yet implemented") - client gating only.
07 - Component Library (Houses Module)
- 1.
HouseColorCard(list grid card) - 2.
HouseHeader(detail banner) - 3.
MemberTile(member row) - 4.
ColorPickerField(editor field) - 5.
HouseCountBadge(members count) - 6.
ConflictFieldError(shared, module-configured) - 7.
DeleteHouseDialog(confirm) - 8. Composition recipes
- 9. Component inventory vs. shared
Module-specific widgets on top of the shared library in 00-shared/03 (Component Library) and tokens in 00-shared/02 (Design Tokens). Shared primitives reused here (not redefined):
AppListPage,AppCard,AppListTile,AppButton,AppTextField,AppBottomSheet,AppDialog,AppMenu,AppFAB,AppSkeleton,AppEmptyState,AppOfflineBanner,AppSnackbar,AppPagination,AppChips,AppBadge,AppAvatar.
1. HouseColorCard (list grid card)
| Prop | Type | Default |
|---|---|---|
house | House | - |
onTap | VoidCallback? | - |
onEdit / onDelete | VoidCallback? | null (hidden when null) |
selectedColor | Color | resolved via HouseColorResolver (11 §3) |
- Layout: 16 dp swatch strip on top (height 56), name (titleMedium), code chip + motto 1-line ellipsis (bodySmall muted).
- States: default / pressed (elevation+
m-fast) / loading skeleton variant. - a11y: semantics label
"House <name>, code <code>"; never color-only. - Gesture: tap → detail;
AppMenu(ellipsis top-right, admin only).
2. HouseHeader (detail banner)
| Prop | Type |
|---|---|
house | House |
actions | List<Widget> (menu) |
- Full-width banner painted with resolved house color; dark scrim for contrast
(06 §2.5); name displayLarge white, code chip
onPrimary, motto italic. - Skeleton variant height 180.
- a11y: banner is decorative; name/code/motto are real text nodes.
3. MemberTile (member row)
| Prop | Type |
|---|---|
admissionNumber | String |
name (from joined student record, (planned) fuller payload) | String? |
onTap | VoidCallback? |
AppAvatarwith initials (deterministic hue from admission number - not house color, keeps house swatch meaningful), leading badgeAppBadge"member".- Row shows admissionNumber; name only when the students payload provides it
(today the list returns full student docs -
student.schema.ts:16-63- so name must be derived from the linked user, which the houses flow does not resolve; render admissionNumber-first(planned)name enrichment). - Empty state variant handled by parent (06 §2.3).
4. ColorPickerField (editor field)
| Prop | Type |
|---|---|
value | String? (hex #RRGGBB as stored, house.schema.ts:15-16) |
onChanged | ValueChanged<String?> |
presets | List<ColorToken> from 11 §3 |
- Renders 8 preset swatch buttons (selected ring) + custom hex
AppTextField(validates#RRGGBB). - Contrast hint: shows pass/warn against white text (00-shared/09).
- Semantics: each swatch
Semantics(button, label: "color <name>").
5. HouseCountBadge (members count)
| Prop | Type |
|---|---|
count | int? (null = unverified) |
unverified | bool |
- Verified (server count,
(planned)endpoint):AppBadge"<n> members". - Unverified (client join today):
"~<n> members"+ tooltip "server-side member count planned". Prevents presenting an estimated count as authoritative (06 §2.3).
6. ConflictFieldError (shared, module-configured)
- Reused from 00-shared/03; rendered under the code field with server message
verbatim:
House code "X" already exists.(houses.service.ts:19-21). - a11y: live-region announcement; focus moves to offending field.
7. DeleteHouseDialog (confirm)
- Extends
AppDialog: title "Delete house?", body lists consequences:- Soft delete (
houses.service.ts:51-54) - house disappears from lists. - Members keep
houseId- dangling refs (student.schema.ts:41-42); no server guard/cascade today(planned).
- Soft delete (
- Destructive action styled
error, confirm text "Delete". - a11y: focus trap, destructive-first reading order (00-shared/09).
8. Composition recipes
| Screen | Composition |
|---|---|
| House list (05 §1) | AppListPage + HouseColorCard grid + AppPagination + AppFAB |
| House detail (05 §2) | HouseHeader + members header (HouseCountBadge) + MemberTile list |
| House editor (05 §3) | AppBottomSheet/AppDialog + AppTextField x3 + ColorPickerField + AppButton + ConflictFieldError |
9. Component inventory vs. shared
| Widget | Owned by | Why not shared |
|---|---|---|
HouseColorCard | houses | house-specific data shape + swatch layout |
HouseHeader | houses | banner composition |
MemberTile | houses (student data rendered here) | cross-module display concern |
ColorPickerField | houses | free-string color semantics (server stores any string) |
HouseCountBadge | houses | verified/unverified duality |
DeleteHouseDialog | houses | delete semantics without server guard |
08 - Form Specifications (Houses Module)
- 1. Form map
- 2. Client validation (mirror of DTO)
- 3. Conflict handling (the critical path)
- 4. Update-mode quirk (PATCH semantics)
- 5. Submission flow
- 6. Idempotency note
- 7. a11y & motion
The house form (create + edit share one form, 05 §3 / 06 §3). Field rules are a client mirror of
CreateHouseDto(create-house.dto.ts:4-22). Form behavior conventions from 00-shared/03 and validation patterns from 00-shared/07.
1. Form map
| Field | Widget | Required | DTO rule | Server behavior |
|---|---|---|---|---|
| Name | AppTextField | ✅ | @IsString() (create-house.dto.ts:5-7) | trimmed (house.schema.ts:9-10) |
| Code | AppTextField | ✅ | @IsString() (create-house.dto.ts:9-11) | trimmed (house.schema.ts:12-13); unique per tenant (house.schema.ts:23) |
| Color | ColorPickerField | ❌ | @IsOptional() @IsString() (create-house.dto.ts:13-16) | stored verbatim, any string (house.schema.ts:15-16) |
| Motto | AppTextField (multiline) | ❌ | @IsOptional() @IsString() (create-house.dto.ts:18-21) | stored verbatim (house.schema.ts:18-19) |
2. Client validation (mirror of DTO)
| Field | Client rule | Error copy |
|---|---|---|
| Name | non-empty after trim | "Name is required." |
| Code | non-empty after trim | "Code is required." |
| Color | #RRGGBB or preset token | "Use a hex color like #BA1A1A." |
| Motto | ≤ 120 chars (soft cap, not server-enforced) | - |
- Server returns 400 for DTO violations (class-validator) - client mirrors first;
any server 400 maps to the field by
message/property(00-shared/07 §4).
3. Conflict handling (the critical path)
- Duplicate code on create → 409
House code "X" already exists.(houses.service.ts:19-21). Rendered byConflictFieldErrorunder Code (07 §6), verbatim; focus moves to Code. - Duplicate code on update → same service path? No:
updatedoes not re-check duplicates (houses.service.ts:42-49) - it relies on the unique index{tenantId, code}(house.schema.ts:23). MongoDBE11000surfaces as a generic server error; client must still render 409-style copy from the API error mapping (00-shared/07 §4) - QA item 14 §2.5. - Duplicate name or color → allowed by design (no check, no index); client may warn softly ("Another house uses this color") but must not block.
4. Update-mode quirk (PATCH semantics)
PATCH /houses/:id reuses CreateHouseDto as the body type
(houses.controller.ts:44); name + code are required in the request body even for
edits. The form therefore:
- Prefills all four fields from the loaded house.
- Submits the complete field set on Save (never partial).
- Documents this in code:
// houses: PATCH body = CreateHouseDto (full set), see houses.controller.ts:44.
If the API later gets a true partial DTO this behavior changes - keep it in one
submission mapper (HouseFormCubit.toDto()).
5. Submission flow
HouseFormCubit:
validate() -> fieldErrors
submit() -> create: POST /houses (houses.controller.ts:24-28)
-> update: PATCH /houses/:id (houses.controller.ts:42-46)
409 code -> conflicts['code'] = server message (single-flight, keep fields)
404 -> snackbar "House not found" + close sheet (update race)
success -> pop sheet; list/detail refetch (response-driven, 13 §6)
6. Idempotency note
- Create is not idempotent: double-tap protection via
submittingflag (single-flight inHouseFormCubit,13§3). A retry after a timed-out 201 would hit 409 - recover by "Go to house" (conflict action navigates to detail if the code now exists). - Update is naturally idempotent (
$setfull DTO).
7. a11y & motion
- Labels linked to inputs; error live-region announcements (
00-shared/09). - Submit disabled until required fields non-empty (but keep 409 handling enabled for races).
- Field error shake
m-fast(00-shared/08); focus first invalid field.
09 - User Behaviour (Houses Module)
- 1. Behaviours observed (from source + domain)
- 2. Trust & estimation behaviour
- 3. Error-handling behaviour
- G. Gaps the behaviour exposes (flagged
(planned)unless noted)
Observed/derived user behaviour patterns and the design decisions they force. Behavioural baselines (offline, motion, a11y) live in 00-shared/08-10; this file records houses-specific behaviours and the gaps they expose.
1. Behaviours observed (from source + domain)
| # | Behaviour | Evidence / rationale | Design consequence |
|---|---|---|---|
| B1 | Admins create houses in a single batch at year start | Houses are few (typically 4-8); list is never huge | Grid of cards, no heavy pagination UI; pagination still wired (houses.service.ts:25-34) |
| B2 | Code is the identity key, name is the label | Unique index on code only (house.schema.ts:23); service 409 on code (houses.service.ts:19-21) | Code shown as chip; duplicates of name allowed (no block, 08 §3) |
| B3 | Colour is the fast-recognition channel | color is a free string (house.schema.ts:15-16) | Tokenized swatches (11 §3); never color-only semantics |
| B4 | Assignment happens at admission, not at the house | houseId lives on the student (student.schema.ts:41-42), set via student DTOs (create-student.dto.ts:39-42) | Houses module must not fake a "manage members" write surface; deep-link to student edit instead |
| B5 | Reassignment = PATCH student, keeps history silently | update $sets houseId (student.service.ts:142-155); transfer keeps house (:185-192) | No history UI (no server audit field); accept and document |
| B6 | Users expect member counts on a house | Core detail-screen ask | Client-join today + HouseCountBadge verified/unverified duality (07 §5) |
| B7 | Deletion is rare and consequential | Soft delete only (houses.service.ts:51-54) | Confirm dialog with consequence copy (07 §7) |
2. Trust & estimation behaviour
- Never present estimated data as authoritative: member count from the client
join is a filtered snapshot, not a server truth →
~prefix (07 §5). - Never claim protection the backend lacks: no delete guard exists, so the dialog says "members keep their house reference" instead of promising a cascade.
3. Error-handling behaviour
| Situation | User expectation | Implementation |
|---|---|---|
| Duplicate code | "That code is taken" | 409 verbatim inline (houses.service.ts:19-21, 07 §6) |
| House deleted mid-edit | "It's gone" | 404 → snackbar + pop (08 §5) |
| Offline | Still see houses | cached list + offline banner, mutations blocked (00-shared/10 §2, 13 §6) |
| Update race (duplicate code via index) | clear message | error map → conflict copy (08 §3, 14 §2.5) |
G. Gaps the behaviour exposes (flagged (planned) unless noted)
- G1 Member list/filter: no
houseIdfilter onGET /students(student.service.ts:106filters{};PaginationQueryDtohas only page/limit/sort/q) and noGET /houses/:id/members. Client joins full student list - unacceptable past a few hundred students.(planned)GET /students?houseId=or members endpoint. - G2 Delete cascade: deleting a house leaves dangling
student.houseId(student.schema.ts:41-42); no 409, no unset.(planned)guard or cascade event. - G3 Identity fields: mascot, house master, points absent from
house.schema.ts:9-19- the domain asks for them (B6/B7).(planned). - G4 Events: no
HouseCreated/Updated/DeletedorStudentHouseAssignedevents (students module emitsStudentCreated/Updatedbut nothing house-specific,student.service.ts:79-91, 146-155).(planned)if other modules react. - G5 List sorting/search:
sort/qparams exist (pagination-query.dto.ts:21-29) but houses list ignores them (houses.service.ts:30).(planned).
10 - Interaction Specification (Houses Module)
- 1. Navigation interactions
- 2. Editor sheet interactions
- 3. List interactions
- 4. Detail interactions
- 5. Motion budget
- 6. Offline & degraded mode (00-shared/10 §2)
- 7. Permission-gated interactions
- 8. a11y interaction notes (00-shared/09)
Concrete interaction rules for the Houses module. Motion tokens (
m-fast,m-base) and gesture standards from 00-shared/08 (Interaction & Motion); accessibility from 00-shared/09; offline behaviour from 00-shared/10.
1. Navigation interactions
| From | Gesture | Result | Rule |
|---|---|---|---|
| List card | tap | push /houses/:id | haptic light on phones |
| List FAB | tap | open editor sheet (create) | gated houses.create |
| Detail edit menu | tap | open editor sheet (update, prefilled) | gated houses.update |
| Detail delete menu | tap | open DeleteHouseDialog (07 §7) | gated houses.delete |
| Member row | tap | push /students/:id | cross-module; deep link back |
| AppBar back | swipe-back / button | pop | standard |
2. Editor sheet interactions
| Action | Behaviour |
|---|---|
| Open (create) | sheet slides up m-base (320 ms); focus Name |
| Open (update) | same; fields prefilled from loaded house |
| Color swatch tap | selects; ring indicator m-fast; haptic light |
| Custom hex input | validates on blur; invalid → field error + shake m-fast |
| Submit (Enter / button) | single-flight; button spinner; fields disabled |
| 409 conflict | ConflictFieldError under Code + shake + focus + live-region |
| Dismiss with dirty form | discard-confirm dialog (first time only, then silent) |
| Success | sheet closes m-base; list/detail refetch (response-driven, 13 §6) |
3. List interactions
| Action | Behaviour |
|---|---|
| Pull-to-refresh | refetch page 1 (keep scroll position) |
| Pagination | footer AppPagination; disabled Prev on page 1, Next when !meta.hasNext (pagination-query.dto.ts:52-53) |
| Card press | elevation + m-fast; pressed state ≤ 200 ms perceived |
4. Detail interactions
| Action | Behaviour |
|---|---|
| Pull-to-refresh | refetch house + members section independently (13 §4) |
| Members refresh icon | refetch students join only; spinner on count badge |
| Delete confirm | destructive button error; confirm → DELETE → snackbar "House deleted" → pop to list; failure → snackbar + dialog stays |
5. Motion budget
| Element | Token (00-shared/08) |
|---|---|
| Card appear stagger | m-fast (≤4 rows) |
| Sheet slide / dialog scale | m-base |
| Field error shake | m-fast |
| Colour swatch selection | m-fast, 120 ms |
| Cross-screen push | platform default; no custom hero except HouseHeader color bridge (list card swatch → detail banner, m-base) |
6. Offline & degraded mode (00-shared/10 §2)
| Mode | Behaviour |
|---|---|
| List cached | render cached houses + AppOfflineBanner; refresh disabled |
| Detail cached | show from list cache; members section shows stale join or hidden |
| Mutation | blocked; FAB/menu disabled with tooltip "You're offline" |
| Conflict while offline | queued? No - mutations never queue; user retries manually |
7. Permission-gated interactions
| Permission | Visible | Hidden behaviour |
|---|---|---|
houses.read absent | nothing | empty state + "contact your admin" (06 §1.3) |
houses.create absent | no FAB | - |
houses.update/delete absent | no card menu | - |
| 403 on direct deep link | AppEmptyState + back | never auto-redirect loop |
Source: permissions.constants.ts:46-49; server RBAC not wired - client gating only
(AGENTS.md).
8. a11y interaction notes (00-shared/09)
- All interactive elements ≥ 48 dp hit area; cards expose
onTapsemantics. - Focus order: header → members → meta; menus traversable by keyboard.
- Error announcements via live region, never visual-only.
- Color swatches carry labels; selection announced ("Color Red selected").
11 - Design System Mapping (Houses Module)
- 1. Token mapping (from 00-shared/02)
- 2. Component → token wiring (from 00-shared/03)
- 3. House colour resolution (critical path)
- 4. Preset palette (8 swatches for
ColorPickerField) - 5. Typography & spacing
- 6. Motion tokens
- 7. Dark mode & theming
Maps the Houses module onto the shared design system: tokens in 00-shared/02 (Design Tokens) and system mapping in 00-shared/04 (Design System Mapping). The critical module problem:
coloris a free string stored on the server (house.schema.ts:15-16) - the client must map it to design tokens safely.
1. Token mapping (from 00-shared/02)
| House field | Design token | Notes |
|---|---|---|
| name | text/display (detail) / text/title (card) | typography scale |
| code | text/label chip | mono-ish? no - standard label, uppercase |
| motto | text/body italic, color/text-muted | - |
| colour surfaces | house palette → color/primary-adjacent swatch set | see §3 |
| destructive (delete) | color/error | DeleteHouseDialog CTA |
| offline / conflict | color/warning banners | shared AppOfflineBanner |
2. Component → token wiring (from 00-shared/03)
| Widget (07) | Token bindings |
|---|---|
HouseColorCard | swatch strip = resolved house colour; text onSurface; chip surface-variant |
HouseHeader | banner = house colour; text onPrimary over scrim |
MemberTile | avatar hue derived from admission number (deterministic, not house colour) |
ColorPickerField | 8 preset swatches from house palette (see §4) |
3. House colour resolution (critical path)
Server stores any string (house.schema.ts:15-16); the client must never trust it
as a valid Color. Resolver order:
- Exact match against a known palette table (name or hex) → token colour.
- Hex parse (
#RRGGBB,#RRGGBBAA) → validated; fall through on failure. - Unknown/named colour not in table → deterministic fallback: hash of
codepicks a token from the house palette (stable per house, so lists don't shift). - Missing
color→ neutral token (surface-variant).
Color resolveHouseColor(String code, String? color) {
if (color != null) {
final hex = _tryParseHex(color);
if (hex != null) return hex;
final named = _palette[color.toLowerCase()];
if (named != null) return named;
}
return _palette.values.elementAt(code.hashCode % _palette.length); // stable fallback
}
- Contrast: banner text is always
onPrimaryover a 24% black scrim - arbitrary user colours pass AA by construction (00-shared/09).
4. Preset palette (8 swatches for ColorPickerField)
| Name | Hex | Contrast vs white text |
|---|---|---|
| Crimson | #BA1A1A | AA+ |
| Royal Blue | #0B57D0 | AA+ |
| Emerald | #146C2E | AA+ |
| Gold | #8A5A00 | AA+ |
| Purple | #5B21B6 | AA+ |
| Teal | #00695C | AA+ |
| Maroon | #7B1E3B | AA+ |
| Slate | #455A64 | AA+ |
All swatches resolve through the same resolver (§3) so a stored hex round-trips
identically. QA: contrast matrix test in 14 §6.
5. Typography & spacing
| Element | Token |
|---|---|
| Card name | text/title-medium, weight 600 |
| Card motto | text/body-small, 1-line ellipsis |
| Detail name | text/display-small |
| Code chip | text/label-large, letter-spacing 0.5 |
| Members header | text/title-medium + count AppBadge |
| Grid gutters | space/16; card padding space/16; banner height 180 |
6. Motion tokens
| Element | Token (00-shared/08) |
|---|---|
| Card stagger | m-fast (150 ms, 40 ms stagger) |
| Sheet/dialog | m-base (320 ms, easeOut) |
| Swatch select | m-fast (120 ms) |
| Hero (swatch → banner) | m-base |
7. Dark mode & theming
- House colours are brand colours: used identically in light/dark; the scrim keeps
AA on both (
00-shared/02dark tokens). - Fallback neutral in dark =
surface-variant-dark. - Golden test for the 8 presets × light/dark (
00-shared/10§6).
12 - API Mapping (Houses Module)
- 1. Houses (
houses.controller.ts:24-52) - 2. Assignment (lives in Students, not Houses)
- 3. Request examples
- 4. Response shapes
- 5. Error map
- 6. House document (schema truth)
- 7. Planned / not yet in source
- 8. Analytics contract
(proposed)
Exact wire contracts for the Houses module. Base path
/api/v1(URI versioning,main.ts); all endpoints JWT-guarded (houses.controller.ts:19-20), tenant-scoped viaHouseRepository→BaseRepository(house.repository.ts:9-15). Envelopes per 00-shared/07 §2-3. Permissions frompermissions.constants.ts:46-49(RBAC guards on endpoints not yet wired -AGENTS.md).
1. Houses (houses.controller.ts:24-52)
| Method | Path | Permission | DTO / source | Description |
|---|---|---|---|---|
| POST | /houses | houses.create | CreateHouseDto (create-house.dto.ts:4-22) | Create; 409 dup code (houses.service.ts:19-21); unique index {tenantId, code} (house.schema.ts:23) |
| GET | /houses?page=1&limit=20 | houses.read | - | List, paginated meta, no sort (houses.service.ts:25-34; defaults houses.controller.ts:32) |
| GET | /houses/:id | houses.read | - | Get by ID; 404 House not found. (houses.service.ts:36-40) |
| PATCH | /houses/:id | houses.update | CreateHouseDto (reused - houses.controller.ts:44) | Update via $set; 404 (houses.service.ts:42-49); no dup check (index backs it) |
| DELETE | /houses/:id | houses.delete | - | Soft delete (houses.service.ts:51-54); no member guard; 404 |
2. Assignment (lives in Students, not Houses)
| Method | Path | Permission | DTO / source | Description |
|---|---|---|---|---|
| POST | /students | student.create | CreateStudentDto incl. optional houseId (create-student.dto.ts:39-42) | Create student with house |
| PATCH | /students/:id | student.update | UpdateStudentDto incl. optional houseId (update-student.dto.ts:45-48) | Reassign house (student.service.ts:142-155) |
- Transfer does not touch
houseId(student.service.ts:185-192). - Reference:
student.schema.ts:41-42(houseId→ref: 'House', optional).
3. Request examples
POST /api/v1/houses
{ "name": "Reddy House", "code": "REDDY", "color": "#BA1A1A", "motto": "Brave and Bold" }
POST /api/v1/houses
{ "name": "Nehru House", "code": "NEHRU" } // color/motto optional
PATCH /api/v1/houses/64f1c2a9e8b1d2c3f4a5b6c7
{ "name": "Reddy House", "code": "REDDY", "color": "#C62828", "motto": "Brave and Bold" }
// note: full CreateHouseDto required by PATCH (houses.controller.ts:44)
POST /api/v1/students
{ "userId": "64f...", "admissionNumber": "ADM2026001", "academicYearId": "64f...",
"gradeId": "64f...", "sectionId": "64f...", "classId": "64f...", "houseId": "64f..." }
PATCH /api/v1/students/64f...
{ "houseId": "64f..." } // reassign house
4. Response shapes
- List:
{ data: HouseDoc[], meta: { page, limit, totalItems, totalPages, hasNext, hasPrevious } }(buildPaginationMeta,pagination-query.dto.ts:41-55). - Single/created/updated:
{ data: HouseDoc }(envelope interceptor,00-shared/07). - Delete: 200/204 envelope, no body (
houses.service.ts:51-54). - House doc fields:
_id, name, code, color?, motto?, tenantId, createdBy?, updatedBy?, isDeleted, deletedAt?, deletedBy?, version, createdAt, updatedAt(house.schema.ts:8-19,base.schema.ts:8-34).
5. Error map
| Code | Meaning | Source |
|---|---|---|
| 401 | unauthenticated | JwtAuthGuard (houses.controller.ts:19) |
| 404 | house missing | houses.service.ts:38, 47, 53 |
| 409 | duplicate code (create) | houses.service.ts:19-21 |
| 409 | duplicate code (update, via unique index) | house.schema.ts:23 - not service-checked (08 §3) |
| 400 | DTO validation (class-validator) | create-house.dto.ts |
| 500 | any Mongo error not mapped | e.g. E11000 race on create (14 §2.4) |
6. House document (schema truth)
| Field | Type | Required | Notes |
|---|---|---|---|
| name | String | ✅ | trim (house.schema.ts:9-10) |
| code | String | ✅ | trim; unique per tenant (house.schema.ts:12-13, 23) |
| color | String | ❌ | free string (house.schema.ts:15-16) |
| motto | String | ❌ | (house.schema.ts:18-19) |
7. Planned / not yet in source
| Path / contract | Status | Source |
|---|---|---|
GET /students?houseId= filter or GET /houses/:id/members | (planned) | student.service.ts:106 filters {}; no endpoint exists |
Delete guard / cascade on DELETE /houses/:id | (planned) | houses.service.ts:51-54 no check; student.schema.ts:41-42 |
sort/q on GET /houses | (planned) | params exist (pagination-query.dto.ts:21-29), unused (houses.service.ts:30) |
| Mascot / house master / points fields | (planned) | absent from house.schema.ts:9-19 |
| House domain events | (planned) | houses service emits none |
| Bulk house import | (planned) | IMPLEMENTATION_PLAN.md:172 (bulk import framework) |
| QR member check-in, push results | (forward-looking) | no contract in source |
8. Analytics contract (proposed)
houses.list.*, houses.detail.*, houses.editor.* events per 00-shared/10 §8 -
not implemented on any client or server (SDK open).
13 - State Management (Houses Module)
- 1. Cubit map
- 2. HouseListCubit
- 3. HouseFormCubit (create + edit share shape)
- 4. HouseDetailCubit + HouseMembersCubit
- 5. Cross-cutting
Per-screen Cubit/Bloc design on top of 00-shared/06 conventions (stack:
flutter_bloc+get_it; server state via dio repository;LoadState= Initial/Loading/Success/Error(ApiException)). Mermaid diagrams included.
1. Cubit map
| Cubit | Screen (05) | Data |
|---|---|---|
HouseListCubit | 1 | page, limit, List<House>, PaginationMeta |
HouseDetailCubit | 2 | House, members (joined list + verified flag) |
HouseFormCubit | 3 | form model, field errors, conflicts, submit |
HouseMembersCubit | 2 (members section) | List<StudentBrief> from students join, (planned) server endpoint swap |
Repositories (HouseRepository in features/houses/data/) are the only layer
touching HTTP; they map envelopes to models and throw
ApiException(status, message) (00-shared/06 §2-3). Student data is fetched via
the students module's repository (cross-module data reuse, never via the houses
repository).
2. HouseListCubit
stateDiagram-v2
[*] --> Initial
Initial --> Loading: fetch(page)
Loading --> Success: 200 envelope
Loading --> Error: 401/500
Success --> Loading: nextPage/prevPage/pullToRefresh
Success --> Error: refetch fails (keep stale)
Error --> Loading: retry
Success --> Success: create OK (prepend or refetch page 1)
Success --> Success: delete OK (refetch page 1)
- Fetch:
GET /houses?page&limit(houses.controller.ts:30-34);metadrives pagination (buildPaginationMeta,pagination-query.dto.ts:41-55). - No server sort (
houses.service.ts:30) - client sorts bycreatedAtasc for stable card order. - Mutations never optimistically mutate the grid; after create/delete the cubit refetches page 1 (response-driven; keeps conflict surfaces honest).
3. HouseFormCubit (create + edit share shape)
sequenceDiagram
participant S as Screen
participant F as HouseFormCubit
participant R as HouseRepo
participant A as API
S->>F: init(house?) // null = create
S->>F: submit(dto)
F->>R: create(dto) | update(id, dto)
R->>A: POST /houses | PATCH /houses/:id
A-->>R: 409 ConflictException | 201/200 doc
R-->>F: Success(doc) | Conflict(message)
F-->>S: SubmitDone / FieldError(code: message)
Note over S: 409 copy verbatim - houses.service.ts:19-21
Note over S: update 404 -> ApiException -> snackbar + pop (08 §5)
- State fields:
saved: bool,submitting: bool(single-flight),conflicts: Map<String, String>(server 409 verbatim). toDto()always returns the full field set (PATCH requires it,houses.controller.ts:44,08§4).- Update-mode duplicate code surfaces as generic 500/E11000 from the index
(
house.schema.ts:23) - error mapper normalizes to conflict copy (08§3).
4. HouseDetailCubit + HouseMembersCubit
stateDiagram-v2
[*] --> Initial
Initial --> Loading: load(id)
Loading --> Loaded: GET /houses/:id
Loading --> NotFound: 404 (pop + snackbar)
Loaded --> Refreshing: pullToRefresh (keep data)
Loaded --> Loaded: members refreshed separately
Members --> MembersLoading: loadMembers(houseId)
MembersLoading --> MembersLoaded: join GET /students filtered client-side
MembersLoading --> MembersError: 401/500 (inline retry, house stays)
MembersLoaded --> MembersEmpty: filter yields []
- House fetch:
GET /houses/:id(houses.controller.ts:36-40); 404House not found.(houses.service.ts:38). - Members: client join today -
GET /students?page&limit(student.controller.ts:41-43) thenwhere(houseId == id);verified: false(07§5). Swap to(planned)GET /students?houseId=/ members endpoint with zero cubit change if the repository abstracts it. - Members state is independent: one section's failure never blanks the header
(per-section
LoadState,06§2.3).
5. Cross-cutting
- Cache: house list cached in memory (
Hiveoptional) per tenant; detail reads cache-first then refreshes (offline tolerance,00-shared/10§2). Members join is never cached as truth - only the filtered snapshot withverified: false. - Ref resolution (student forms): house picker options come from
HouseListCubitcache orGET /houses; no per-picker fetch. - Events as hints: no house domain events exist (G4,
09); client refresh is response-driven, not event-driven. IfHouseUpdatedevents land(planned), the detail cubit can refresh on hint - do not build the plumbing now. - Permission gating: cubits expose
canCreate/canUpdate/canDeletefrom RBAC (permissions.constants.ts:46-49); UI hides actions accordingly (10§7). - Planned cubits:
HousePointsCubit/HouseEventsCubit(planned)- no data contract; do not scaffold (IMPLEMENTATION_PLAN.mdhas no houses items).
14 - QA Checklist (Houses Module)
- 1. Core flows (must pass)
- 2. Validation & conflicts (negative paths)
- 3. Reassignment & transfer semantics
- 4. Soft-delete & reference integrity (the big gap)
- 5. Multi-tenancy
- 6. UI/UX (manual + golden)
- 7. E2E regression seeds (manual fixtures)
- 8. Backend follow-ups (flagged to engineering)
Module-specific quality gates on top of 00-shared/10 (QA baseline: performance budgets, offline matrix, a11y, motion, security). Every item is testable and maps to source.
1. Core flows (must pass)
| # | Scenario | Expectation | Source |
|---|---|---|---|
| 1.1 | Create house → appears in list | POST 201; doc in GET list, createdAt order (no server sort) | houses.controller.ts:24-34, houses.service.ts:30 |
| 1.2 | Create house with only name+code | 201; color/motto absent (optional, create-house.dto.ts:13-21) | house.schema.ts:15-19 |
| 1.3 | House detail → identity fields render | name, code, color, motto round-trip verbatim (trim only) | houses.service.ts:36-40, house.schema.ts:9-19 |
| 1.4 | Update house → PATCH applies | $set full DTO; untouched fields stable | houses.service.ts:42-49, houses.controller.ts:44 |
| 1.5 | Soft delete → list excludes it | isDeleted: true filtered by scoped query | houses.service.ts:51-54, base.repository.ts |
| 1.6 | Reassign student house via PATCH /students/:id | houseId changes; student detail reflects | update-student.dto.ts:45-48, student.service.ts:142-155 |
2. Validation & conflicts (negative paths)
| # | Scenario | Expectation | Source |
|---|---|---|---|
| 2.1 | Create with duplicate code | 409 verbatim House code "X" already exists. | houses.service.ts:19-21; index house.schema.ts:23 |
| 2.2 | Create with duplicate name (different code) | Allowed (no check/index) - document as by-design; UI soft-warns | houses.service.ts:19-21 checks code only |
| 2.3 | Duplicate color across houses | Allowed - palette conflict is a UX concern, not API error | house.schema.ts:15-16 |
| 2.4 | Concurrent create of same code (race) | One 201, other → Mongo E11000 → 500 as wired today; gap: should map to 409 (planned) | house.schema.ts:23, error filter |
| 2.5 | Update code to an existing one | No service check (houses.service.ts:42-49); unique index throws → generic error; client normalizes to conflict copy (08 §3) | gap |
| 2.6 | Missing name or code | 400 (class-validator @IsString required) | create-house.dto.ts:5-11 |
| 2.7 | Invalid color (e.g. blue!) | Accepted (free string) - client must validate hex before send | create-house.dto.ts:13-16 |
| 2.8 | Unauthenticated call | 401 | houses.controller.ts:19-20 |
3. Reassignment & transfer semantics
| # | Scenario | Expectation | Source |
|---|---|---|---|
| 3.1 | Transfer student (class change) | houseId unchanged (transfer $set excludes house) | student.service.ts:185-192 |
| 3.2 | Clear a student's house | PATCH with houseId omitted/null - field optional | update-student.dto.ts:45-48 |
| 3.3 | Set house on create | houseId IsMongoId accepted | create-student.dto.ts:39-42 |
| 3.4 | houseId pointing at a deleted house | Allowed; no referential validation - students show "house unavailable"; client must render fallback | gap (09 G2) |
| 3.5 | House member count after reassignment | Changes only after refetch (client join) - expect ~ unverified count | 07 §5, 13 §4 |
4. Soft-delete & reference integrity (the big gap)
| # | Scenario | Expectation | Source |
|---|---|---|---|
| 4.1 | Delete house with members | No 409, no cascade - soft delete succeeds; students keep dangling houseId (student.schema.ts:41-42). Client warns pre-delete (07 §7); server guard (planned) | houses.service.ts:51-54 |
| 4.2 | Delete already-deleted house | 404 (scoped filter excludes deleted) | houses.service.ts:53 |
| 4.3 | Recreate house with same code after delete | Allowed (soft-deleted row excluded by scoped query) - old students still point at the deleted _id, new house gets a new _id: historical membership silently orphaned. Document + test | base.repository.ts scoped filter |
| 4.4 | Detail of deleted house via stale link | 404 → "House not found" UX | houses.service.ts:38 |
5. Multi-tenancy
| # | Scenario | Expectation | Source |
|---|---|---|---|
| 5.1 | Tenant B queries houses | sees only B's rows | base.repository.ts scoped filter (house.repository.ts:9-15) |
| 5.2 | Cross-tenant duplicate codes | allowed - uniqueness per tenant | house.schema.ts:23 |
| 5.3 | tenantId in request body | ignored - server never reads it from body | AGENTS.md |
6. UI/UX (manual + golden)
| # | Check |
|---|---|
| 6.1 | Pagination boundaries: page 1 Prev disabled; last page Next disabled (meta.hasNext/hasPrevious) |
| 6.2 | 409 inline error under Code; focus + live-region |
| 6.3 | PATCH full-set prefill: edit opens with all 4 fields filled (08 §4) |
| 6.4 | Color round-trip: swatch selected → stored hex → detail banner identical (11 §3-4) |
| 6.5 | Contrast matrix: 8 presets × light/dark × white text ≥ AA (11 §4) |
| 6.6 | Unverified count shows ~ + tooltip; verified count shows exact |
| 6.7 | Offline: cached list + banner; mutations blocked (00-shared/10 §2) |
| 6.8 | Empty states: list, detail members, search-less grid |
| 6.9 | Permission gating: read-only role sees no FAB/menu; 403 copy on deep link |
| 6.10 | Skeleton < 200 ms perceived; content < 2 s on network (00-shared/10 §1) |
| 6.11 | Delete dialog copy accurate: "members keep their house reference" (no fake cascade promise, 09 §2) |
7. E2E regression seeds (manual fixtures)
- 2 tenants, identical house codes (proves 5.2 isolation).
- House with a
#-less color value and one withcolormissing (fallback path, 11 §3). - Student assigned to a house that is then soft-deleted (4.1, 4.3 orphan check).
- 3 houses with the same name, different codes (2.2 by-design duplicates).
- Duplicate-code create race reproduced by two parallel POSTs (2.4).
8. Backend follow-ups (flagged to engineering)
| # | Item | Priority |
|---|---|---|
| 1 | Map E11000 race to 409 on create (2.4) | High |
| 2 | Duplicate-code check on update (2.5) | Medium |
| 3 | GET /students?houseId= or members endpoint (G1) | High |
| 4 | Delete guard / cascade for assigned students (G2) | High |
| 5 | sort/q on houses list (G5) | Low |
| 6 | House events + RBAC guards on endpoints (IMPLEMENTATION_PLAN.md:239 audit) | Medium |
15 - Flutter Implementation Guide (Houses Module)
- 1. Feature folder
- 2. Model (mirror schema exactly)
- 3. Repository (dio)
- 4. Cubits
- 5. Screens
- 6. Validation (client mirror of DTOs)
- 7. Navigation (go_router)
- 8. DI registration (get_it)
- 9. Permissions & auth
- 10. Testing
- 11. Forward-looking hooks
Module-specific build guide on top of 00-shared/11 (Flutter architecture, get_it DI, dio, go_router, flutter_bloc) and 00-shared/06 (state patterns). Order = recommended implementation sequence; each step maps to source files.
1. Feature folder
lib/features/houses/
data/
dto/house_dto.dart # envelope-payload mapper
repositories/house_repository.dart # houses API only
domain/
models.dart # House (immutable)
presentation/
cubits/house_list_cubit.dart, house_detail_cubit.dart,
house_form_cubit.dart, house_members_cubit.dart
screens/houses_list_screen.dart, house_detail_screen.dart
widgets/house_color_card.dart, house_header.dart, member_tile.dart,
color_picker_field.dart, house_count_badge.dart,
delete_house_dialog.dart, conflict_field_error.dart
Widgets per 07_Component_Library.md; screens per 05/06. Students data lives in
lib/features/students/ - houses imports its repository for the member join
(13 §4) but owns no student model copy.
2. Model (mirror schema exactly)
House - house.schema.ts:8-19 + base.schema.ts:8-34:
id (String ObjectId), name, code, color?, motto?, plus createdAt/updatedAt
(DateTime, server sends strings - parse defensively) and version (int, read-only).
Immutable class + fromJson/toJson (repository maps envelope data).
Never send tenantId/isDeleted/version (base.schema.ts:10-11, 20-21, 30-31).
3. Repository (dio)
- Endpoints per
12_API_Mapping.md; base/api/v1+ApiBearerAuth(houses.controller.ts:17-20). fetch(page, limit)→ parsedata+meta {page, limit, totalItems, totalPages, hasNext, hasPrevious}(pagination-query.dto.ts:41-55).create(dto)/update(id, dto)/getById(id)/remove(id).- Typed errors:
ApiException(409, message)- message verbatim forConflictFieldError(houses.service.ts:19-21);ApiException(404)for detail. - Update sends the full DTO body (PATCH =
CreateHouseDto,houses.controller.ts:44). - Member join: separate students repository call (cross-module), single-flight per
00-shared/11"AppDio".
4. Cubits
Per 13_State_Management.md: implement HouseListCubit first (pagination pattern),
then HouseFormCubit (with conflicts map), then HouseDetailCubit +
HouseMembersCubit. Use shared LoadState sealed class and pagination mixin
(00-shared/06 §3.1-3.2).
5. Screens
| Order | Screen | Key widgets | Source |
|---|---|---|---|
| 1 | Houses list | HouseColorCard grid, AppPagination, FAB | 06 §1 |
| 2 | House editor (sheet/dialog) | ColorPickerField, ConflictFieldError | 06 §3 |
| 3 | House detail | HouseHeader, HouseCountBadge, MemberTile | 06 §2 |
6. Validation (client mirror of DTOs)
- Form rules per
08§2: name/code requiredIsString(create-house.dto.ts:5-11); color optional string, client enforces#RRGGBB(create-house.dto.ts:13-16); motto optional (:18-21). - Conflict messages: render server text verbatim (
07§6). - Color resolver before any paint:
11§3 (resolveHouseColor).
7. Navigation (go_router)
/houses list (auth + houses.read)
/houses/:id detail | deep-linkable
Create/edit forms = bottom sheets (phone) / dialogs (tablet), not routes
(00-shared/03). Member rows deep-link to /students/:id (students module).
Planned: /houses/:id/members standalone (planned) when server endpoint lands.
8. DI registration (get_it)
getIt.registerLazySingleton<HouseRepository>(() => HouseRepository(getIt()));
// students repository already registered by the students feature (imported for join)
// cubits factory-registered per screen (pass params via constructor args)
9. Permissions & auth
- Gate FAB/menu on RBAC (
permissions.constants.ts:46-49); 403 handling per00-shared/07; JWT viaAppDiointerceptor (00-shared/11). - Note: RBAC guards on endpoints are not yet wired in the backend
(
AGENTS.md"Not yet implemented") - client still gates UI.
10. Testing
- Unit: cubit state transitions incl. 409 →
Conflict, 404 →NotFound+ pop, members section failure isolation (13§2-4);resolveHouseColorfallback paths (11§3); modelfromJsonfor absent color/motto. - Widget: golden per screen (
00-shared/10§6); color contrast matrix (14§6.5); delete dialog copy. - Integration: mock dio with fixture envelopes; pagination boundary tests.
- E2E seeds per
14§7.
11. Forward-looking hooks
- Points / leaderboard / events screens
(planned)- no backend contract (IMPLEMENTATION_PLAN.mdhas no houses items); do NOT scaffold ahead of the schema (house.schema.ts:9-19). - QR member check-in
(forward-looking):mobile_scannerdependency flagged in00-shared/11§1. - Push on house events
(forward-looking): notification channels undefined. - Analytics events
houses.*(proposed)(05Analytics section).
01 — Product Overview (i18n Module)
- 1. What this module is
- 2. What it is today (implemented)
- 3. Storage model
- 4. Product value
- 5. Scope boundary — PRD native-app exclusion
- 6. Non-goals (today)
- 7. Definitions
Backend + app-facing view of StudyLyon's internationalization. Grounded in
src/shared/i18n/andsrc/modules/i18n/; shared conventions per 00-shared/01, 00-shared/05.
1. What this module is
StudyLyon ships an i18n framework: a request-scoped locale resolver
(I18nMiddleware), a translation service with per-locale JSON catalogs and
English fallback, and one authenticated API endpoint that serves the message
catalog for the resolved locale.
2. What it is today (implemented)
| Capability | Status | Source |
|---|---|---|
Locale resolution from Accept-Language | live | src/shared/i18n/i18n.middleware.ts:13-18 |
Translation lookup + {param} interpolation | live | src/shared/i18n/i18n.service.ts:32-45 |
| Message catalog endpoint | live | src/modules/i18n/i18n.controller.ts:13-17 |
Catalogs: en, hi shipped | live | src/shared/i18n/translations/ |
| 7 further locales declared | planned (data-only) | i18n.service.ts:9-21 |
| Admin translation editor / key CRUD | (planned) | no code — IMPLEMENTATION_PLAN.md:171 |
| Crowdin + CI drift check | (planned) | IMPLEMENTATION_PLAN.md:845 |
3. Storage model
Translations are static JSON files compiled into an in-memory
CATALOGS record at boot (i18n.service.ts:6). There is no MongoDB
collection for translations, and catalogs are global — not
tenant-scoped (deliberate: UI strings are product-owned, tenant data is not).
See 04_Information_Architecture.md.
4. Product value
- One codebase, 9 target Indian locales (en, hi, ta, te, kn, ml, gu, mr, bn).
- API responses, notification copy and (planned) app UI in the user's language without per-tenant translation data.
- Fallback chain guarantees a string is always returned, even when a
translation is missing (
i18n.service.ts:40).
5. Scope boundary — PRD native-app exclusion
The original PRD scoped the web/API platform and explicitly excluded the native mobile app. These design docs cover the mobile/Flutter surface introduced later (see 00-shared/11_Flutter_App_Architecture.md) plus the admin console surfaces for translation management (planned). The server-side i18n service itself is in production scope and fully described in 12_API_Mapping.md.
6. Non-goals (today)
- Per-tenant custom translations (no tenantId on catalogs).
- Runtime translation editing (catalogs are static; editing is (planned)).
- Right-to-left locales (all 9 supported locales are LTR; RTL is (forward-looking) — see 07_Component_Library.md).
- Translation of tenant-authored content (announcement bodies, thread titles) — that is content, not UI strings.
7. Definitions
- Locale — lowercase BCP-47 primary tag, e.g.
en,hi,ta. - Catalog — one JSON file of
key → stringpairs per locale. - Key — dotted path, e.g.
errors.resourceNotFound. - Interpolation —
{name}placeholders substituted at translate time (i18n.service.ts:42-44).
02 — User Personas (i18n Module)
- 1. Ravi — Student (consumes translated UI)
- 2. Meera — School Admin (consumes + indirectly authors)
- 3. Aarav — Platform Admin / Translation Manager (planned)
- 4. Dev / Content Ops — key hygiene
- 5. What personas do NOT do
Who touches translation infrastructure. Components per 00-shared/03; global IA per 00-shared/05.
1. Ravi — Student (consumes translated UI)
- Age 17, Grade 12, prefers Hindi on his phone (
Accept-Language: hi-IN). - Never touches translation tooling; only consumes the catalog the app
downloads via
GET /api/v1/i18n/messages. - Failure mode: a missing
histring silently falls back to English (i18n.service.ts:40) — fine for Ravi, invisible to him. - Needs: complete catalogs in his locale, correct pluralization
(
leave.requestedwith{days}).
2. Meera — School Admin (consumes + indirectly authors)
- Uses the web admin console; toggles her account locale.
- Triggers translated system notifications (e.g. announcement published)
which currently hardcode English in
notifications.handler.ts:16-31(planned fix: key-based titles**)**. - Needs: system messages in her locale, not English-only.
3. Aarav — Platform Admin / Translation Manager (planned)
- New persona for the (planned) admin translation console.
- Reviews missing keys per locale, edits translations, watches drift.
- Needs: per-locale editor, missing-key view, placeholder validation, sync status (which locale is behind).
- Today he would edit
en.json/hi.jsonby hand and deploy — no UI.
4. Dev / Content Ops — key hygiene
- Adds new product strings; keeps keys in dotted namespaces
(
errors.*,leave.*,announcement.*). - Needs: CI drift check (planned) per
IMPLEMENTATION_PLAN.md:845("CI check for missing translations") and Crowdin extraction.
5. What personas do NOT do
- No tenant admin edits translations at runtime (per-tenant catalogs are a non-goal — 01_Product_Overview.md §6).
- No student/staff chooses a locale in-app yet; locale comes only from the
Accept-Languageheader via middleware (i18n.middleware.ts:13-18). In-app locale switching is (forward-looking).
03 — User Journey (i18n Module)
- 1. J1 — App cold start in a supported locale (student Ravi,
hi) - 2. J2 — Unsupported/absent locale (teacher with
fror no header) - 3. J3 — Missing key in a shipped catalog (drift)
- 4. J4 — Translation manager edits a key (planned)
- 5. J5 — New locale onboarding (planned)
- 6. Journey map
End-to-end journeys around locale resolution, catalog delivery and translation management. Motion/interaction conventions per 00-shared/08; QA baseline per 00-shared/10.
1. J1 — App cold start in a supported locale (student Ravi, hi)
- Ravi opens the app; device sends
Accept-Language: hi-IN, hi;q=0.9, en;q=0.8. I18nMiddlewaretakes the first tag, strips region and case:hi-IN→hi, whitelisted inSUPPORTED_LOCALES(i18n.middleware.ts:16-17,i18n.service.ts:11-21).- App calls
GET /api/v1/i18n/messages?locale=hi(JWT-authenticated,i18n.controller.ts:13-17); receives the fullhicatalog. - UI strings render in Hindi. Server-rendered messages (e.g. errors) use
I18nService.translate()with the same resolved locale. Success: consistent Hindi everywhere.
2. J2 — Unsupported/absent locale (teacher with fr or no header)
- Middleware leaves
ctx.localeunset (frnot in whitelist). getLocale()returns'en'(i18n.service.ts:27-30).GET /i18n/messagesreturns the English catalog (getMessages→DEFAULT_CATALOG,i18n.service.ts:47-49). Success: graceful English fallback, never an empty payload.
3. J3 — Missing key in a shipped catalog (drift)
- Developer adds
report.readytoen.jsonbut nothi.json. - A
hiuser triggers the message.translate('report.ready')finds nohientry → falls back toDEFAULT_CATALOG(i18n.service.ts:37-40). - User sees English inside an otherwise Hindi screen.
Today: silent. (planned): CI drift check
(
IMPLEMENTATION_PLAN.md:845) fails the build so this never ships.
4. J4 — Translation manager edits a key (planned)
- Aarav opens the (planned) translation console, filters locale
ta, statusmissing. - Edits the key in the per-locale editor (06_Screen_Specifications.md §2).
- Saves → validation checks placeholders match the
ensource (08_Form_Specifications.md §2) → updated catalog published. - Next app fetch (
GET /i18n/messages) returns the new string. (Publishing mechanics — file/DB, per-tenant or global — are (planned) and unresolved; see 00-shared/12.)
5. J5 — New locale onboarding (planned)
- Platform admin adds locale
knto the locale-management screen. - Catalog created empty → all keys show
missing. - Translators fill in; when a coverage threshold is met,
knis promoted toSUPPORTED_LOCALES— today this is a code+deploy change (i18n.service.ts:9-21is a code constant).
6. Journey map
| Step | Actor | System action | Outcome |
|---|---|---|---|
| Header sent | device | middleware whitelist check | locale or en |
| Fetch catalog | app | GET /i18n/messages?locale= | full catalog, fallback en |
| Render string | app/server | key lookup + {param} fill | localized text |
| Missing key | — | catalog → en → key | never-empty guarantee |
| Edit key (planned) | admin | console CRUD | next fetch serves it |
04 — Information Architecture (i18n Module)
- 1. Data model
- 2. Catalog contents today
- 3. Locale taxonomy
- 4. Resolution chain (server)
- 5. Admin console IA (planned)
- 6. Naming rules
How translation data is organized. Global IA per 00-shared/05; API envelope conventions per 00-shared/07.
1. Data model
Translation (logical entity; today a JSON entry, no DB):
| Field | Type | Notes | Source |
|---|---|---|---|
key | string | dotted namespace, e.g. errors.resourceNotFound | en.json:3 |
locale | string | primary BCP-47 tag | i18n.service.ts:11-21 |
value | string | localized template, may contain {param} | en.json:9 |
Physical storage: one file per locale under
src/shared/i18n/translations/<locale>.json, compiled to the in-memory
CATALOGS record (i18n.service.ts:6). Not a Mongo collection; not
tenant-scoped (no tenantId).
2. Catalog contents today
| Namespace | Keys | Example |
|---|---|---|
app.* | app.welcome | "Welcome to StudyLyon" (en.json:2) |
errors.* | errors.resourceNotFound, errors.conflict, errors.validation, errors.unauthorized, errors.forbidden, errors.internal | en.json:3-8 |
leave.* | leave.requested ({days} param) | en.json:9 |
announcement.* | announcement.published | en.json:10 |
9 keys total in each of en.json and hi.json (identical key sets today).
3. Locale taxonomy
- Shipped (file exists):
en,hi. - Declared (in
SUPPORTED_LOCALES, no file yet; data-only addition peri18n.service.ts:9-10):ta,te,kn,ml,gu,mr,bn. - Fallback:
enis the universal fallback (DEFAULT_CATALOG,i18n.service.ts:7). - All 9 are LTR. No RTL locale is planned; RTL support is (forward-looking).
4. Resolution chain (server)
Accept-Language → middleware whitelist → ctx.locale (i18n.middleware.ts:16-17)
ctx.locale → CATALOGS lookup → 'en' (i18n.service.ts:27-30)
translate(): locale catalog → en catalog → literal key (i18n.service.ts:37-40)
5. Admin console IA (planned)
Translation Console (/admin/i18n)
├── Dashboard — coverage %, missing per locale, drift alerts
├── Keys — searchable key list, per-key status
├── Key Editor — source (en) + per-locale fields (06 §2)
├── Locales — manage supported locales, promote threshold (06 §3)
└── (settings) — Crowdin sync, CI drift check (IMPLEMENTATION_PLAN.md:845)
6. Naming rules
- Keys are dotted namespaces ordered
<module>.<entity>.<state>. - Params are
{snake_case}; a key's params must be identical across locales (enforced (planned) in editor + CI). errors.*mirrors exception semantics — one key per error class, kept in sync withcommon/filters/http-exception.filter.ts.
05 — Screen Inventory (i18n Module)
- Legend
- 1. Translation Key List (
/admin/i18n/keys) (planned) - 2. Key Editor (
/admin/i18n/keys/:key) (planned) - 3. New Key (
/admin/i18n/keys/new) (planned) - 4. Locale Management (
/admin/i18n/locales) (planned) - 5. Translation Sync / Drift view (
/admin/i18n/sync) (planned) - 6. Mobile app surface
- Shared components used
- Analytics events (proposed)
- Keyboard, landscape, tablet, desktop
Every screen of the i18n surface: one public API-facing screen (none — the mobile app has no dedicated i18n screen; it consumes catalogs) plus the (planned) admin translation console. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics.
Legend
States = idle / loading / success / empty / error(offline, rate, invalid) /
disabled / permission. Analytics follow {module}.{screen}.{action}
(proposed; SDK open — 00-shared/10 §8).
1. Translation Key List (/admin/i18n/keys) (planned)
| Field | Detail |
|---|---|
| Purpose | Browse, search, filter all translation keys across locales |
| Entry | admin console home; console nav |
| Exit | row tap → Key Editor; FAB → New Key; Locale chip → Locale Management |
| Source | GET /api/v1/i18n/keys (planned) — list with per-locale status |
| Widgets | AppSearchBar, AppDataTable/AppListTile, AppChips (locale filter), AppBadge (missing, stale, ok), AppEmptyState, AppSkeleton |
| Row data | key, namespace group, params {days}, coverage badge, last edited |
| States | loading skeleton; empty ("No keys"); error (401 → login); permission-denied (no i18n.* role) |
| Sort/filter | by namespace, by missing-in-locale, by placeholder presence |
| Bulk actions (planned) | select keys → bulk edit / delete / export |
| Analytics (proposed) | i18n.keys.{open,search,filter,open_row,create} |
2. Key Editor (/admin/i18n/keys/:key) (planned)
| Field | Detail |
|---|---|
| Purpose | View/edit one key's value in every locale |
| Entry | row tap in Key List; inline "missing" badge CTA |
| Exit | save → back to list; delete → confirm dialog |
| Layout | master-detail on tablet: key list left, editor right |
| Content | read-only key + params chips; per-locale AppTextFields (en source + all shipped/declared locales), one tab/tile per locale; missing locales highlighted |
| Validation | placeholders must match source (see 08_Form_Specifications.md §2); non-empty if locale shipped |
| Save | PUT /api/v1/i18n/keys/:key (planned); optimistic, rollback on fail |
| States | dirty indicator, per-field save state, conflict → reload prompt |
| Analytics (proposed) | i18n.key.{open,edit,save,delete,params_mismatch} |
3. New Key (/admin/i18n/keys/new) (planned)
| Field | Detail |
|---|---|
| Purpose | Create a key + source value in one step |
| Form | key (dotted path, uniqueness), params chips (optional), en value required, optional first-edit of other locales |
| Create | POST /api/v1/i18n/keys (planned) → jumps to Key Editor |
| States | duplicate-key 409 inline |
4. Locale Management (/admin/i18n/locales) (planned)
| Field | Detail |
|---|---|
| Purpose | View supported locales, coverage %, promote/demote, add new locale |
| Source | GET /api/v1/i18n/locales (planned) |
| Widgets | AppCard per locale: name, tag, coverage ring (missing/total), status chip (shipped/declared), "add locale" |
| Actions | add locale (creates empty catalog), coverage drill-down → Key List filtered |
| Note | today SUPPORTED_LOCALES is a code constant (i18n.service.ts:11-21); promoting a locale is a deploy. The console makes this data-driven (planned) |
5. Translation Sync / Drift view (/admin/i18n/sync) (planned)
| Field | Detail |
|---|---|
| Purpose | CI/Crowdin drift report: which keys are missing/stale per locale |
| Source | GET /api/v1/i18n/drift (planned) or CI artifact (IMPLEMENTATION_PLAN.md:845) |
| Content | table of locale × missing keys; "diff" of placeholder mismatches; export CSV |
| CTA | "Open missing in editor" |
6. Mobile app surface
No dedicated screen. The app fetches the catalog once
(GET /api/v1/i18n/messages?locale=, i18n.controller.ts:13-17), caches it,
and renders through Flutter's intl/ARB (see 15_Flutter_Implementation_Guide.md).
In-app locale switching is (forward-looking).
Shared components used
AppTextField, AppButton, AppSnackbar, AppCard, AppListTile,
AppDataTable, AppBottomSheet, AppDialog, AppMenu, AppSkeleton,
AppEmptyState, AppOfflineBanner, AppChips, AppBadge, AppSearchBar,
AppFAB. Module-specific: per-locale field group, coverage ring —
defined in 07_Component_Library.md.
Analytics events (proposed)
i18n.keys.{open,search,filter,open_row,create},
i18n.key.{open,edit,save,delete,params_mismatch},
i18n.locale.{open,add,promote}, i18n.sync.{open,export}.
Keyboard, landscape, tablet, desktop
- Key Editor: per-locale fields in scrollable columns; tablet splits list/detail; desktop constrains form width ≤ 720 dp.
- All fields keyboard-avoidance +
ScrollViewresizeToAvoidBottomInset. - Devanagari/Indic IMEs: fields must not force Latin keyboards; keep
TextCapitalization.nonefor translation fields.
06 — Screen Specifications (i18n Module)
- 0. Live flow: catalog delivery (no screen)
- 1. Translation Key List (
/admin/i18n/keys) (planned) - 2. Key Editor (
/admin/i18n/keys/:key) (planned) - 3. New Key (
/admin/i18n/keys/new) (planned) - 4. Locale Management (
/admin/i18n/locales) (planned) - 5. Sync / Drift view (
/admin/i18n/sync) (planned) - 6. Edge cases across screens
- 7. Motion summary
Detailed specifications for the (planned) admin translation console and the live catalog-delivery flow. Largest file: every screen's anatomy, states, behaviors, edge cases, a11y, motion. Components per 00-shared/03; motion tokens per 00-shared/08; a11y baseline per 00-shared/09.
0. Live flow: catalog delivery (no screen)
The only production surface today. The app:
- Resolves locale via
Accept-Language(middleware,i18n.middleware.ts:13-18) — or passes?locale=explicitly. - Calls
GET /api/v1/i18n/messages?locale=hi→ fullRecord<string,string>(i18n.controller.ts:13-17,getMessagesfallback to en,i18n.service.ts:47-49). - Caches locally; refreshes on locale change or app update (cache version = catalog hash (planned)).
Contract edge cases:
locale=xx(unknown) → English catalog (i18n.service.spec.ts:48-51).- Missing header → English.
- Response is a flat key→string map; no nested structure — the app must flatten ARB/JSON to the same flat map for lookup parity.
1. Translation Key List (/admin/i18n/keys) (planned)
Anatomy
AppBar "Translations" [Sync status chip] [FAB +]
SearchBar (debounced, min 2 chars)
Filter chips: All | missing:hi | missing:ta | has-params | namespace:errors
────────────────────────────────────────────────────────
DataTable / ListTile rows:
key (mono, truncated) · params chips · coverage badge · edited
[Pagination: page size 25, "load more"]
States
| State | Behavior |
|---|---|
| loading | AppSkeleton rows (10); search disabled until first paint |
| success | rows with per-locale coverage badge (ok = all shipped locales present, missing = ≥1 shipped locale lacks key) |
| empty | AppEmptyState "No keys" + CTA "Create first key"; distinguishes "no filters match" vs "zero keys" |
| error | 401 → redirect login; 403 (no i18n.* perm (planned)) → permission state with copy; 5xx → retry CTA |
| offline | AppOfflineBanner + cached last data (stale-while-revalidate) |
Behaviors
- Row tap → Key Editor (§2). Long-press → multi-select for bulk delete/export.
- Locale filter chips are multi-select;
missing:<locale>preselects keys the given locale lacks (drill-down from Locale Management). - Search matches key + source value (en) + params.
- Keyboard:
/focuses search. Esc clears filters. - Motion: rows stagger
m-fast; filter change animates listm-basefade.
a11y
- Search labeled "Search translation keys"; results announced count
(
live region). - Badges have text labels ("missing in hi"), not color-only.
- Focus lands on first row after search completes.
2. Key Editor (/admin/i18n/keys/:key) (planned)
Anatomy (tablet/desktop master-detail; phone single pane)
Header: key (mono) · params chips {days} · [Delete] [Save]
Tabs (desktop: side rail, phone: horizontal chips): en · hi · ta · te · kn · ml · gu · mr · bn
Per-locale field card:
[locale tag + name] [status chip: shipped|declared|missing]
AppTextField (multiline ≥3 rows, auto-grow)
└─ placeholder checker: "params match source" / "missing {days}" (inline, amber)
Dirty indicator per field (dot); unsaved-changes guard on exit.
States
| State | Behavior |
|---|---|
| loading | skeleton; key header + params only |
| loaded | per-locale values from GET /api/v1/i18n/keys/:key (planned) |
| missing locale | field empty + amber outline + chip missing; block "promote" of a locale with missing keys |
| saving | field-level spinner; Save disabled while pending |
| saved | snackbar "Saved" (module-level); per-field check animates |
| conflict | stale version → dialog "Reload or overwrite" |
| error | field error inline; network → AppOfflineBanner, no data loss (drafts kept in memory) |
Behaviors
- Placeholder parity check (critical) — runs on every edit:
value.match(/\{(\w+)\}/g)compared against theensource set (i18n.service.ts:42-44regex). Mismatch = amber inline warning, Save allowed but flagged; CI drift check (planned) (IMPLEMENTATION_PLAN.md:845) blocks promotion. - Empty value in shipped locale → Save blocked with red inline error
(empty string would degrade the never-empty guarantee,
i18n.service.ts:40). - Delete → AppDialog confirm (destructive,
dangerbutton); deletes key in all locales. Undo snackbar 5 s (planned). - Unicode note: Devanagari/Tamil/Telugu etc. fields keep
TextCapitalization.none; no autocorrect;maxLines≥ 3, soft wrap. - Character counter: warn > 200 chars (push/payload limits (forward-looking)).
a11y
- Per-field
Semanticslabels "value in hi". - Status changes (missing → saved) announced via live region.
- Tab navigation order: source (en) first, then locales, actions last.
3. New Key (/admin/i18n/keys/new) (planned)
Anatomy
Form (bottom sheet on phone, card on desktop, ≤ 720 dp):
key (dotted path; mono; prefix helper "errors., leave., …")
params (AppChips input — free text chips, e.g. {days}, {name})
en value (required)
hi value (optional; shipped → recommended)
[Create key]
Rules & states
| Rule | Behavior |
|---|---|
| key format | /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$/; invalid → inline error |
| duplicate | 409 → inline "key exists" + "Open existing" link |
| placeholder self-check | en value's {...} tokens must ⊆ declared params; mismatch → block Create |
| create | POST /api/v1/i18n/keys (planned) → navigate to Editor with snackbar |
| error | 5xx → retry; offline → banner |
a11y
- Key field hint: "lowercase, dots separate namespaces".
- Errors linked to fields; first invalid focused.
4. Locale Management (/admin/i18n/locales) (planned)
Anatomy
Grid of AppCards (2 cols phone, 4 cols desktop):
[flag/script glyph] en — English [status chip shipped]
Coverage ring: 9/9 · "Add locale" dashed card
[Bottom sheet: add locale — pick from SUPPORTED_LOCALES minus shipped]
States
| State | Behavior |
|---|---|
| loading | card skeletons |
| empty | no declared locales → full-width add CTA |
| partial coverage | ring amber + "x missing" link → Key List filtered missing:<locale> |
| promote ready | coverage 100% + no param drift → "Promote to shipped" enabled (makes SUPPORTED_LOCALES include it; today code-level i18n.service.ts:9-21 (planned: data-driven)**) |
| demote | confirm dialog; blocking if it is the only locale with a key |
Behaviors
- Adding a locale creates an empty catalog — every key shows
missing(drift flow,03_User_Journey.md §J5). - Coverage = present keys / shipped-locale keys per locale.
- RTL note: if an RTL locale (ar/ur) is ever added (forward-looking),
this screen shows a
directionfield; all LTR today.
5. Sync / Drift view (/admin/i18n/sync) (planned)
Anatomy
Header: last CI run · [Run now] [Export CSV]
Table: locale × missing keys (count) → expand to key list → jump to editor
Placeholder drift panel: key, source params, locale params, [Fix]
States
- success / stale ("last run 3 d ago") / failed (CI unavailable).
- Empty: "All locales in sync" with celebratory success state.
6. Edge cases across screens
| Case | Handling |
|---|---|
hi.json missing a key | falls back to en at runtime (i18n.service.ts:40); editor shows missing; never blank string |
| Param reorder/rename between locales | flagged in editor + sync; runtime keeps literal {name} if param absent (i18n.service.ts:43) |
| Huge catalog (>10k keys) | list paginates; fetch returns flat map (memory: ~1 MB per locale — acceptable; app caches one locale) |
| Locale promoted mid-session | app refetches catalog on next launch; version header (planned) enables delta push (forward-looking) |
| Concurrent editors | last-write-wins today; optimistic lock (version field) (planned) — conflict dialog §2 |
| Offline admin console | read-only cached view; saves queue (planned); catalog delivery to mobile unaffected (server-side) |
7. Motion summary
- List/filter transitions:
m-base(200 ms) fade+slide. - Field save:
m-fastcheck morph. Error shake reserved for form submit. - Coverage ring: animates on data load
m-baseease-out.
07 — Component Library (i18n Module)
- 1. PerLocaleFieldGroup (module-specific)
- 2. CoverageRing
- 3. ParamsChips
- 4. LocaleStatusChip
- 5. DriftTableRow
- 6. LocaleFieldRail (tablet/desktop)
- 7. Reuse map
- 8. Typography/locale notes
Components specific to the i18n surface; shared components come from 00-shared/03_Component_Library.md (AppTextField, AppCard, AppListTile, AppChips, AppBadge, AppSnackbar, AppDialog, AppDataTable, AppSkeleton, AppEmptyState, AppOfflineBanner, AppSearchBar, AppFAB). Design tokens per 00-shared/02; module behavior per 06_Screen_Specifications.md.
1. PerLocaleFieldGroup (module-specific)
| Field | Detail |
|---|---|
| Purpose | One locale's value editor inside the Key Editor |
| Props | locale, label (native name), status (shipped/declared/missing), value, params (expected set), onChanged, saving |
| Children | AppTextField (multiline ≥3 rows, auto-grow, TextCapitalization.none) + status chip + placeholder checker line |
| Placeholder checker | inline text: "params match source" (success) / "missing {days}" (amber) / "unexpected {x}" (error) — regex \{(\w+)\} (i18n.service.ts:42-44) |
| States | idle / dirty (dot) / saving (spinner) / saved (check) / missing (amber outline) / error (red outline + message) |
| a11y | single Semantics node "value in {locale}"; status via live region |
| Directionality | directionality: TextDirection.ltr for all current locales (all LTR). RTL locale (forward-looking) → per-field TextDirection.rtl driven by locale tag, never hardcoded |
2. CoverageRing
| Field | Detail |
|---|---|
| Purpose | Locale coverage in Locale Management + key-list badge |
| Props | present, total, size (36 default) |
| Behavior | progress arc; amber when < 100 %, green at 100 %; text n/n center |
| a11y | semantic label "coverage 8 of 9 in hi" — never color-only |
3. ParamsChips
| Field | Detail |
|---|---|
| Purpose | Display/enter {param} tokens |
| Props | params (string[]), editable |
| Behavior | chip text without braces (e.g. days); editing appends {...} tokens; invalid chars blocked (regex \w+) |
| Used in | Key Editor header, New Key form, Key List rows |
4. LocaleStatusChip
| Field | Detail |
|---|---|
| Purpose | shipped vs declared vs missing state |
| Variants | shipped (filled), declared (outline), missing (amber), stale (danger outline) |
| a11y | text label always present |
5. DriftTableRow
| Field | Detail |
|---|---|
| Purpose | Sync view row: locale × missing keys with jump-to-editor |
| Behavior | expandable; "Fix" button deep-links Key Editor?filter=missing:<locale> |
6. LocaleFieldRail (tablet/desktop)
| Field | Detail |
|---|---|
| Purpose | Vertical locale tab rail in Key Editor (desktop) |
| Behavior | per-locale status dot on rail; scroll-lock to active field |
7. Reuse map
| Screen | Shared components | Module-specific |
|---|---|---|
| Key List | AppSearchBar, AppChips, AppBadge, AppDataTable, AppSkeleton, AppEmptyState, AppFAB | CoverageRing (badge), ParamsChips, LocaleStatusChip |
| Key Editor | AppTextField, AppButton, AppSnackbar, AppDialog, AppMenu | PerLocaleFieldGroup, ParamsChips, LocaleFieldRail |
| New Key | AppTextField, AppButton, AppChips, AppBottomSheet | ParamsChips |
| Locale Management | AppCard, AppButton, AppBottomSheet | CoverageRing, LocaleStatusChip |
| Sync | AppDataTable, AppButton, AppCard | DriftTableRow |
8. Typography/locale notes
- Devanagari, Tamil, Telugu, Kannada, Malayalam, Gujarati, Marathi, Bengali
scripts render with system fonts; avoid forced
fontFamilyfor these (Indic glyph fallback required). KeepfontFamilynull for translation text. - Text scale: translation fields respect
textScaleFactorup to 2.0 (00-shared/09). - Placeholders never auto-correct; autocorrect off.
08 — Form Specifications (i18n Module)
- 1. New Key form (
/admin/i18n/keys/new) (planned) - 2. Key Editor — per-locale field validation (the critical contract)
- 3. Add Locale form (
/admin/i18n/locales) (planned) - 4. Sync export (
/admin/i18n/sync) (planned) - 5. Runtime parity guarantee
Forms in the (planned) translation console: New Key, Key Editor (per-locale fields), Add Locale, Sync export. Validation derives from the live service contract (
i18n.service.ts:32-45) so the editor can never author a string the runtime can't render.
1. New Key form (/admin/i18n/keys/new) (planned)
| Field | Type | Required | Constraints / validation |
|---|---|---|---|
key | AppTextField (mono) | yes | /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$/; unique; ≤ 120 chars; namespace hint errors. leave. announcement. |
params | ParamsChips (editable) | no | tokens match \w+; deduped; order-insensitive |
en value | AppTextField multiline | yes | placeholder self-check: every {token} in value ⊆ declared params (else block); { } escaping not supported (literal {{ unsupported — must match runtime regex \{(\w+)\}) |
| other locales | AppTextField | no | same placeholder check once en exists; empty allowed → missing |
Submit
POST /api/v1/i18n/keys(planned) → 201 → navigate to Key Editor.- Duplicate key → 409 inline "key exists" + "Open existing".
- Offline → banner; values preserved (in-memory draft).
Error messages (translated themselves)
Form errors must use i18n keys (errors.validation etc.) — the console is
dogfooding the module.
2. Key Editor — per-locale field validation (the critical contract)
| Rule | Enforcement | Source of truth |
|---|---|---|
| Placeholder parity | every locale's {token} set == en source set | i18n.service.ts:42-44 (/\{(\w+)\}/g) |
| Missing param | value contains {days} not in source → error: runtime would print literal {days} (i18n.service.ts:43) | runtime fallback behavior |
| Extra param | value uses {x} absent from source → amber warn (translator added token runtime won't fill) | same regex |
| Empty value in shipped locale | error, block save | translate() would return '', breaking never-empty fallback (i18n.service.ts:40) |
| Key immutability | key read-only in editor; rename = delete + create (planned) | — |
| Length | warn > 200 chars (push payload limits (forward-looking)) | — |
Save flow
PUT /api/v1/i18n/keys/:key (planned) with { values: { <locale>: string } }.
Optimistic; on 409 conflict (stale version) → dialog "Reload or overwrite"
(06 §2).
3. Add Locale form (/admin/i18n/locales) (planned)
| Field | Type | Required | Validation |
|---|---|---|---|
| locale tag | select from SUPPORTED_LOCALES minus shipped | yes | must be in i18n.service.ts:11-21 |
| direction | read-only "LTR" (all current locales LTR) | — | RTL (forward-looking) |
| name | AppTextField (native endonym) | yes | non-empty, ≤ 40 chars |
Submit → creates empty catalog → all keys missing → drift flow
(03_User_Journey.md §J5).
4. Sync export (/admin/i18n/sync) (planned)
| Field | Detail |
|---|---|
| Format | CSV: key, en, hi, ta, …, status |
| Filter | missing-only / all / placeholder-drift-only |
| Delivery | download; Crowdin upload is (planned) per IMPLEMENTATION_PLAN.md:845 |
5. Runtime parity guarantee
Whatever the editor allows, runtime behavior is fixed:
- unknown key → returns key literal (
i18n.service.ts:40, speci18n.service.spec.ts:44-46) - unknown locale → English catalog (
i18n.service.spec.ts:48-51) - missing param → literal
{name}retained (i18n.service.ts:43)
The console must therefore treat en as immutable source-of-truth for
params and all other locales as derived — enforced by §2 rules and the CI
drift check (planned).
09 — User Behaviour (i18n Module)
- 1. Consumer behaviours
- 2. Manager behaviours (planned console)
- 3. Failure-moment matrix
- 4. Behavioural rules
Observed behaviors, pain points, and design responses for translation consumers and (planned) managers. Personas in 02; journeys in 03.
1. Consumer behaviours
| Behaviour | Evidence | Design response |
|---|---|---|
| Locale comes from the device, not the app | locale resolved from Accept-Language only (i18n.middleware.ts:13-18); no in-app switcher | Keep header-first resolution; in-app switcher (forward-looking); ?locale= query override exists (i18n.controller.ts:15) |
| Users ignore English fallback | missing hi key renders English silently (i18n.service.ts:40) | Acceptable at runtime; CI drift check (planned) prevents silent drift shipping (IMPLEMENTATION_PLAN.md:845) |
Users notice literal {days} when a param is missing | runtime keeps {name} literal (i18n.service.ts:43) | Param parity validation in editor (08 §2) + tests |
| Region tags confuse resolution | hi-IN handled by split('-')[0] (i18n.middleware.ts:16) | Keep region-stripping; document that en-GB → en |
| App caches catalog aggressively | full map per fetch (getMessages, i18n.service.ts:47-49) | Cache-key = locale; version header (planned) for deltas (forward-looking) |
2. Manager behaviours (planned console)
| Behaviour | Pain today | Console response |
|---|---|---|
| Hand-edits JSON and deploys | en.json/hi.json manual edits; typo = runtime bug | Key Editor with live placeholder check (06 §2) |
| Doesn't know which locales are behind | no coverage signal anywhere | CoverageRing + missing badges (05 §1, 07 §2) |
| Adds a key to en only, ships it | silent English fallback for other locales | Save-time warning + CI gate (planned) |
| Reorders params while translating | runtime shows literal {days} (i18n.service.ts:43) | param parity error blocks save (08 §2) |
| Wants per-tenant copy | non-goal today (global catalogs, 01 §6) | Documented; tenant-catalog variant (forward-looking) |
3. Failure-moment matrix
| Moment | User sees | Correctness | Fix |
|---|---|---|---|
| Unknown key | key literal (e.g. errors.internal2) | degraded but honest | add key; editor search helps |
| Unknown locale | English | correct fallback | — |
| Missing param | Leave requested for {days} day(s). | broken copy visible | parity check blocks |
| Empty value in hi | empty string | worst case — empty UI | block save; never-empty guarantee i18n.service.ts:40 |
| 401 on catalog fetch | offline UI | correct | retry with backoff; cache last good catalog |
4. Behavioural rules
- Never render a blank string. If catalog lookup fails, fall back to
en, then to key — never
''(matchesi18n.service.ts:40). - English is the source of truth for keys and params; every other locale is derived.
- Console edits are global (no tenant dimension) until per-tenant catalogs are introduced (forward-looking).
- No auto-correct on translation fields (Indic scripts, 07 §8).
- Locale ≠ language of content. Tenant-authored announcements
(
announcement.service.ts:47) are content, not UI strings — they are never translated by this module; only system copy (notifications.handler.ts:16-31keys (planned)) is.
10 — Interaction Specification (i18n Module)
- 1. Catalog delivery (live)
- 2. Key List (
/admin/i18n/keys) (planned) - 3. Key Editor (planned)
- 4. Locale Management (planned)
- 5. Sync view (planned)
- 6. Motion tokens (from 00-shared/08)
- 7. Haptic/tactile
- 8. Error feedback matrix
Interaction, motion, and feedback rules for the i18n surface. Tokens and timing per 00-shared/08; states legend per 05_Screen_Inventory.md.
1. Catalog delivery (live)
| Interaction | Specification |
|---|---|
| App launch | fetch catalog for resolved locale (GET /i18n/messages?locale=, i18n.controller.ts:13-17) in parallel with session bootstrap; render with cached catalog first (offline-first), swap when fresh map arrives |
| Locale change | (forward-looking) refetch + rebuild widget tree; keep scroll positions |
| Retry | 3 attempts, exponential backoff 1 s / 2 s / 4 s; offline banner (00-shared/03) |
| Cache versioning (planned) | catalog hash header on response; app refetches when hash differs; delta push (forward-looking) |
2. Key List (/admin/i18n/keys) (planned)
| Gesture/input | Behavior |
|---|---|
| Tap row | open Key Editor (450 ms m-base slide) |
| Long-press | multi-select mode; AppBar shows "3 selected" + bulk bar (delete / export) |
/ | focus search; Esc clears filters |
| Filter chip | immediate list filter with m-base fade (200 ms); badge counts animate |
| Pull-to-refresh | re-pull from GET /api/v1/i18n/keys; silent on error |
| Infinite scroll | "load more" at 25-row pages; skeleton 6 rows while fetching |
Feedback: save/delete → AppSnackbar (module-scoped); errors → inline banner, never blocking.
3. Key Editor (planned)
| Interaction | Behavior |
|---|---|
| Field focus | locale rail scrolls field into view (desktop); phone switches tab |
| Typing | live placeholder parity check (debounced 300 ms); amber line appears without focus loss |
| Save | Ctrl/Cmd+S anywhere; button spinner; success = field check morph m-fast (120 ms) |
| Dirty navigation | unsaved-changes dialog before leaving (discard/cancel) |
| Conflict | 409 → dialog "Reload or overwrite" (06 §2) |
| Delete | destructive confirm; undo snackbar 5 s (planned) |
| Multiline growth | field auto-grows to 6 rows max, then scrolls internally |
4. Locale Management (planned)
| Interaction | Behavior |
|---|---|
| Add locale | bottom sheet (phone) / dialog (desktop); on success → card appears with coverage ring animation m-base ease-out |
| Coverage ring tap | drill into Key List filtered missing:<locale> |
| Promote | enabled only at 100 % coverage + zero param drift; confirm dialog explaining runtime switch-over (new catalog becomes active for that locale) |
| Demote | confirm; blocked if it holds the only copy of any key |
5. Sync view (planned)
| Interaction | Behavior |
|---|---|
| Run now | spinner on button; results stream in rows as CI completes; stale state after 24 h |
| Row expand | DriftTableRow expands missing-key list; "Fix" jumps to editor with filter preset |
| Export | CSV download; success snackbar; failure toast |
6. Motion tokens (from 00-shared/08)
| Token | Use |
|---|---|
m-fast (120 ms) | field check, chip status change, badge update |
m-base (200 ms) | list filter fade, pane slide, coverage ring |
| ease-out | all entrances; no bounce effects on admin surfaces |
7. Haptic/tactile
- Phone: light haptic on save success; warning haptic on param-mismatch line (only on explicit save, not during typing).
- Desktop: none.
8. Error feedback matrix
| Error | Feedback |
|---|---|
| 401 catalog fetch | silent retry ×1, then offline state; no nagging UI |
| 403 console | permission screen + "ask admin" copy (perm i18n.* (planned)) |
| 409 duplicate key | inline field error + "Open existing" link |
| 429 rate limit | countdown chip on Save (00-shared/10) |
| Network lost mid-save | banner "Saved locally, retrying"; draft preserved |
11 — Design System Mapping (i18n Module)
- 1. Token usage
- 2. Component → token mapping
- 3. State → token mapping
- 4. Directionality
- 5. Iconography
- 6. Dark mode / density
Maps i18n surfaces to the shared design system (00-shared/02 tokens, 00-shared/04 mapping). Everything here is (planned) console + mobile catalog consumption; the live API has no UI.
1. Token usage
| Token (00-shared/02) | Where |
|---|---|
color.surface/primary/error/warning | per-locale field states (idle / focus / error / amber mismatch) |
color.text.secondary | placeholder checker line, locale endonyms |
radius.sm/md | chips, field cards, coverage ring background |
spacing.3/4 | per-locale field stacking (≥ 12 dp between locale cards) |
type.mono | keys, params, drift table |
type.body | translation values; no forced font family — Indic glyph fallback (07 §8) |
shadow.elevation-1 | editor field cards on tablet master-detail |
2. Component → token mapping
| Component | Tokens / variants |
|---|---|
| AppTextField (translation value) | multiline variant, TextCapitalization.none, autocorrect off, amber outline = mismatch, red = block error |
| AppChips (params) | {days} rendered as days chip, outline variant |
| AppBadge | missing = amber, ok = green, stale = danger outline |
| AppButton | primary = Save, danger = Delete, secondary = Export |
| AppDialog | destructive variant with danger button for delete/demote |
| AppSnackbar | success/error variants; undo action for delete (planned) |
| AppDataTable | default density, sticky header, hover row highlight (desktop) |
3. State → token mapping
| State | Token |
|---|---|
| missing translation | amber outline + amber text label (never color-only, 00-shared/09) |
| error | color.error + icon + text message |
| success | green check + snackbar |
| disabled | disabled palette; Save disabled while pending / conflict |
| permission | locked icon + secondary copy |
4. Directionality
- All 9 supported locales are LTR (
SUPPORTED_LOCALES,i18n.service.ts:11-21). - UI is LTR today;
TextDirectionmust be driven by locale tag, never hardcoded, so an RTL locale (forward-looking) lights up without layout rewrites (07 §1). - Number formatting: use
intl/NumberFormatper locale in the app (15 §3); catalog values must not hardcode digits.
5. Iconography
| Icon | Meaning |
|---|---|
| translate | module icon (console nav) |
| check-circle | per-field saved |
| warning-amber | placeholder mismatch / stale |
| error-red | blocked state |
| cloud-done / cloud-off | sync status |
| globe | locale cards |
6. Dark mode / density
- Console inherits 00-shared/04; coverage ring uses semantic colors only (works in dark).
- Compact density on desktop (data-heavy console): row height 40 dp in key list.
- Text scale to 2.0 enforced on translation fields (00-shared/09).
12 — API Mapping (i18n Module)
- 1. Live endpoints
- 2. Planned endpoints (admin console)
- 3. Consumer API mapping (server-side translation)
- 4. Integration surface for the Flutter app
Exact endpoints, contracts and RBAC. Envelope conventions per 00-shared/07_API_Conventions.md; versioning
/api/v1URI (src/main.ts:44-46).
1. Live endpoints
GET /api/v1/i18n/messages
| Field | Detail |
|---|---|
| Auth | JWT (JwtAuthGuard, i18n.controller.ts:8) — Bearer token required |
| RBAC permission | none (guard only; no i18n.* perm exists in permissions.constants.ts — verified: all 97 perms, no i18n entries) |
| Query | locale?: string (optional; default = resolved locale) |
| Behavior | locale provided → CATALOGS[locale]; else resolved locale; unknown/missing → English DEFAULT_CATALOG (i18n.service.ts:47-49) |
| Response | flat Record<string,string> (key → localized template), e.g. { "app.welcome": "Welcome to StudyLyon", "leave.requested": "Leave requested for {days} day(s)." } |
| Source | src/modules/i18n/i18n.controller.ts:13-17 → I18nService.getMessages |
Locale resolution (server-side, all routes)
Accept-Language: hi-IN, hi;q=0.9 → first tag → strip region → lowercase →
whitelist check → ctx.locale (i18n.middleware.ts:13-18). Applied globally
(src/app/app.module.ts:139). Read via TenantContextService.getLocale()
(src/shared/tenant/tenant-context.service.ts:58-60).
2. Planned endpoints (admin console)
| Method | Path | Purpose | RBAC (planned) | Status |
|---|---|---|---|---|
| GET | /api/v1/i18n/keys | list keys + per-locale status (paginated, filter/search) | i18n.key.read | (planned) |
| POST | /api/v1/i18n/keys | create key + en source | i18n.key.create | (planned) |
| GET | /api/v1/i18n/keys/:key | one key, all locale values + params + version | i18n.key.read | (planned) |
| PUT | /api/v1/i18n/keys/:key | update values (params parity enforced) | i18n.key.update | (planned) |
| DELETE | /api/v1/i18n/keys/:key | delete key in all locales | i18n.key.delete | (planned) |
| POST | /api/v1/i18n/keys/bulk | bulk create/update (import CSV) | i18n.key.update | (planned) |
| GET | /api/v1/i18n/locales | list locales + coverage | i18n.locale.read | (planned) |
| POST | /api/v1/i18n/locales | add/promote locale | i18n.locale.manage | (planned) |
| GET | /api/v1/i18n/drift | missing-keys + param-drift report | i18n.locale.read | (planned) |
Gap: none of the
i18n.*permissions exist yet — they must be added tosrc/modules/rbac/permissions.constants.tsbefore any console endpoint ships (today: 97 perms,permissions.constants.ts:1-97).
3. Consumer API mapping (server-side translation)
| Consumer | Mechanism | Today | Source |
|---|---|---|---|
| Notifications | event → hardcoded EN title/body | EN-only; drift risk | notifications.handler.ts:16-31,41-49 |
| Announcement publish | AnnouncementPublished event payload title | tenant-authored content — not translated | announcement.service.ts:86-91 |
| Leave request | key leave.requested with {days} exists | no consumer wired yet | en.json:9 |
| Error responses | errors.* keys exist | HTTP filter not yet wired to translate() | en.json:3-8 |
| Planned | notifications + errors consume I18nService.translate() with event/user locale | — | IMPLEMENTATION_PLAN.md:171 |
Interpolation contract (consumers must pass): translate(key, { name: value })
→ {name} replaced; missing params left literal (i18n.service.ts:42-44).
4. Integration surface for the Flutter app
App fetches GET /i18n/messages?locale=<current> → flat map → ARB merge
(15_Flutter_Implementation_Guide.md §2). Keep locale list in sync with
SUPPORTED_LOCALES (i18n.service.ts:11-21): en, hi, ta, te, kn, ml, gu,
mr, bn.
13 — State Management (i18n Module)
- 1. State domain
- 2. Cubit breakdown (console, (planned))
- 3. State flow — catalog delivery (mobile)
- 4. State flow — key editor save (console, (planned))
- 5. Rules
Client state for catalog delivery (mobile) and the (planned) console. Framework conventions per 00-shared/06_State_Management.md.
1. State domain
| State | Lives where | Source |
|---|---|---|
| Locale | server request context (ctx.locale) | tenant-context.service.ts:58-60 |
| Catalog (flat map) | app-side cache (per locale) | getMessages response, i18n.service.ts:47-49 |
| Catalog version/hash (planned) | app cache + response header | — |
| Editor state (keys, dirty, parity) | console Cubits | 06_Screen_Specifications.md |
| Coverage/drift report | console Cubit, fetched on demand | GET /api/v1/i18n/drift (planned) |
2. Cubit breakdown (console, (planned))
| Cubit | State | Events |
|---|---|---|
CatalogCubit | { locale, catalog: Map<String,String>, version, status: loading/ready/error } | fetch(locale), localeChanged, refresh |
KeyListCubit | { keys, filters: {namespace, missingIn: String?, hasParams}, page, hasMore, status } | search(q), toggleFilter(f), loadMore(), bulkDelete(ids) |
KeyEditorCubit | { key, params, values: Map<locale,String>, dirty: Set<locale>, parity: Map<locale,ParityStatus>, version, saving } | load(key), editValue(locale,v), save(), delete(), reload() |
LocaleCubit | { locales: Map<tag,{coverage,direction,status}>, promoting } | load(), add(tag), promote(tag), demote(tag) |
SyncCubit | { report, lastRunAt, exporting } | run(), export() |
Parity computation (pure function, unit-testable):
parity(locale) = diff(valueParams(value), sourceParams(en)) using
/\{(\w+)\}/g (i18n.service.ts:42-44).
3. State flow — catalog delivery (mobile)
flowchart LR
A[App start] --> B[Resolve locale<br/>Accept-Language]
B --> C{Catalog cached?}
C -->|yes| D[Render cached]
C -->|no| E[GET /i18n/messages?locale]
E --> F{200?}
F -->|yes| G[Store map keyed by locale]
F -->|no| H[Retry backoff x3]
H --> I{ok?}
I -->|no| J[Offline state + banner]
G --> D
J --> K[Retry on connectivity]
K --> E
4. State flow — key editor save (console, (planned))
flowchart LR
A[editValue] --> B[Mark dirty]
B --> C[Debounce 300ms parity check]
C --> D{Parity ok?}
D -->|no| E[Amber warn / red block]
D -->|yes| F[Enable Save]
F --> G[PUT /i18n/keys/:key]
G --> H{201?}
H -->|yes| I[Clear dirty + snackbar]
H -->|409| J[Conflict dialog<br/>reload/overwrite]
H -->|network| K[Banner + keep draft]
5. Rules
- Catalog map is immutable after fetch; updates replace the whole map (locale keyed) — no partial mutation.
- Editor keeps in-memory drafts; leaving with
dirtynon-empty triggers the unsaved-changes guard (10 §3). - One locale per catalog fetch; locale switch swaps the map, never merges.
- Console state is local-only today (no server push); WebSocket refresh of coverage (forward-looking).
14 — QA Checklist (i18n Module)
- 1. Runtime contract (must always pass)
- 2. Missing keys / drift
- 3. Placeholder mismatch (highest-value bug class)
- 4. Encoding & Unicode
- 5. Sync across locales
- 6. Performance & offline
- 7. Security & RBAC
- 8. Regression drivers (automated)
- 9. Manual smoke (10 min)
Quality gates for the i18n module. Baseline per 00-shared/10_QA_Baseline.md; runtime contract per
i18n.service.spec.tsandi18n.service.ts:32-49.
1. Runtime contract (must always pass)
-
translate(key)with no locale → English (spec:25-29) -
translate(key)withhicontext → Hindi (spec:31-36) -
interpolation:
translate('leave.requested', {days: 3})→"Leave requested for 3 day(s)."/ Hindi equivalent (spec:38-42) -
unknown key → returns the key literal (
spec:44-46) -
getMessages('xx')→ English catalog (spec:48-51) -
Accept-Language: hi-IN→ localehi;fr→en; absent →en(i18n.middleware.ts:16-17) -
GET /api/v1/i18n/messagesreturns 401 without Bearer token (i18n.controller.ts:8) -
?locale=hireturns hi catalog regardless of header
2. Missing keys / drift
- every shipped-locale file has the same key set (en vs hi parity)
-
CI drift check (planned) fails build when a key is missing in a shipped locale (
IMPLEMENTATION_PLAN.md:845) - runtime missing key shows English, never blank, never crash
-
(planned) editor marks
missinglocales on the key list
3. Placeholder mismatch (highest-value bug class)
- param set equality across locales for every key with params (08 §2)
-
{days}present in value but not in params → runtime prints literal{days}(i18n.service.ts:43) — covered by parity check -
extra
{x}in a translation that source lacks → flagged amber -
malformed braces (
{{days}},{days) — no crash, literal output; documented behavior -
param order differs across locales (e.g. hi puts
{days}first) — must still render correctly (order-insensitive regex)
4. Encoding & Unicode
- all 9 target locales render: Devanagari (hi, mr), Tamil (ta), Telugu (te), Kannada (kn), Malayalam (ml), Gujarati (gu), Bengali (bn)
-
JSON files saved UTF-8; no escaped surrogate corruption (
translations/hi.jsonverified:"संसाधन नहीं मिला।"athi.json:3) -
no mojibake in HTTP response (Content-Type
application/json; charset=utf-8) - Indic text scale to 2.0 without clipping (11 §6)
- no forced Latin keyboard/font on translation fields (07 §8)
-
numbers in translations use locale digits only if the locale does;
{days}values are locale-formatted by the caller (App level)
5. Sync across locales
- editing a key in one locale never touches others (except delete = all)
-
new key in en → appears as
missingfor all other shipped/declared locales - promote/demote locale ((planned)) recomputes coverage ring on all screens
- bulk import ((planned)) validates: duplicate keys, empty values, param drift — rollback on any failure
- concurrent edit conflict (409) → dialog, no silent overwrite (06 §2)
6. Performance & offline
- catalog fetch < 500 ms p95 (small files: 9 keys today)
- app renders from cache when offline; banner shown; no white screens
- list pagination stable at 10k keys ((planned) console)
- no re-fetch loop on repeated 401
7. Security & RBAC
-
no
i18n.*perms exist today (permissions.constants.ts:1-97) — addi18n.key.{read,create,update,delete},i18n.locale.{read,manage}(planned) before console ships - catalog endpoint requires valid JWT; no tenant data leak (catalogs are global by design)
- console endpoints reject cross-tenant key injection (none — global; verify no tenantId path appears)
-
no XSS: catalog values rendered as text, never HTML (
{...}interpolated as text)
8. Regression drivers (automated)
i18n.service.spec.ts— extend with: param order swap, empty value, malformed braces, region-tag resolution,?localeoverride.- Add e2e:
GET /i18n/messagesauth, fallback, encoding (requires MongoDB + Redis,npm run test:e2e). - Key-parity CI script (planned) compares key sets + params across
translations/*.json.
9. Manual smoke (10 min)
- Set phone to
hi-IN→ open app → all strings Hindi. - Set phone to
fr→ all strings English, no errors. - Trigger a leave request notification → check
{days}renders (consumer wiring (planned)). - Admin edits
hivalue with wrong params → Save blocked/amber ((planned)). - Verify
npm run typecheck+npm run lintclean before merge.
15 — Flutter Implementation Guide (i18n Module)
- 1. Contract recap (from source)
- 2. Integration strategy: ARB + remote overlay
- 3. Locale specifics
- 4. Cache & refresh
- 5. Notifications copy (planned wiring)
- 6. Testing
- 7. File checklist
Maps the backend i18n contract onto the Flutter app. Architecture baseline per 00-shared/11_Flutter_App_Architecture.md; state per 13_State_Management.md.
1. Contract recap (from source)
- Catalog endpoint:
GET /api/v1/i18n/messages?locale=→ flatMap<String,String>(i18n.controller.ts:13-17); fallback English (i18n.service.ts:47-49). - Locales:
en, hi, ta, te, kn, ml, gu, mr, bn(i18n.service.ts:11-21); only en+hi shipped today. - Interpolation:
{name}regex\{(\w+)\}(i18n.service.ts:42-44); missing params stay literal{name}. - Fallback chain on server: locale catalog → en → key.
2. Integration strategy: ARB + remote overlay
flowchart LR
A[flutter_localizations] --> B[Localizations app]
B --> C[ARB assets<br/>en.arb, hi.arb]
C --> D[AppStrings<br/>generated: .tr / .of context]
E[GET /i18n/messages?locale] --> F[RemoteCatalog<br/>Map<String,String>]
F --> G[MessageLookup override]
G --> B
- Ship ARB files for offline + hot-reload dev (
flutter gen-l10n). - Add a
RemoteLookupimplementingGlobalWidgetsLocalizations/AppLocalizationsDelegatefallback chain: ARB first, remote overlay second, key literal last — mirroring server order (i18n.service.ts:40: key literal). - Fetch via
CatalogCubit(13 §2): fetch on launch after locale resolve; cache per locale withshared_preferences; version hash (planned) header triggers refetch. - Locale resolution:
WidgetsBinding.instance.platformDispatcher.locale→ match againstSUPPORTED_LOCALES-equivalent constant; fallbacken.
Key → lookup mapping
Backend keys are dotted (errors.resourceNotFound). ARB keys must be
camelCase — map once in a generated KeyMap: errors.resourceNotFound →
errorsResourceNotFound. Never duplicate strings; the ARB file holds
source values and the remote catalog overrides at runtime.
Interpolation mapping
String tr(String key, [Map<String, Object?>? params]) {
var t = remote[key] ?? arb(key) ?? key; // order mirrors server
params?.forEach((k, v) => t = t.replaceAll('{$k}', v.toString()));
return t;
}
- Regex parity: server uses
\{(\w+)\}(i18n.service.ts:42); implement the same on the client so{days}behaves identically offline and online. - Missing param → literal
{days}(matches serveri18n.service.ts:43).
3. Locale specifics
| Item | Guide |
|---|---|
intl setup | Intl.defaultLocale = resolved locale; MaterialApp(localizationsDelegates: ...) |
| Plurals | leave.requested uses {days} with English "(s)" hack — replace with proper pluralization via ARB plural when the key is re-worked (planned) |
| Numbers/dates | use NumberFormat/DateFormat per locale, never hardcode digits in translation values |
| Direction | textDirection from locale tag (all current locales LTR; RTL (forward-looking)) |
| Fonts | no forced fontFamily for Indic text; system fallback (07 §8); test Devanagari/Tamil at 2.0 scale |
| IME | translation-adjacent inputs: TextCapitalization.none, autocorrect off |
4. Cache & refresh
final CatalogCubit catalogCubit; // 13 §2
// launch:
final locale = await resolveLocale(); // platform → whitelist → en
await catalogCubit.fetch(locale); // offline: cached map
// locale switch (forward-looking):
onLocaleChanged(catalogCubit.setLocale); // rebuild Localizations
- Cache key:
catalog_<locale>; TTL 24 h + version-hash refetch (planned); delta push (forward-looking).
5. Notifications copy (planned wiring)
Server notification titles are hardcoded English today
(notifications.handler.ts:16-31); planned: move to keys, e.g.
notification.passwordReset, interpolated server-side per recipient locale
(12_API_Mapping.md §3). Client just renders title/body from the
Notification document (schemas/notification.schema.ts:22-26).
6. Testing
- Golden: catalog parity test — for every key in
en.arb, assertremoteCatalogcontains it (guards missing-key drift; CI script (planned) perIMPLEMENTATION_PLAN.md:845). - Widget test:
tr('leave.requested', {'days': 3})renders both en/hi. flutter test+npm run typecheck+npm run lintbefore merge.
7. File checklist
| File | Purpose |
|---|---|
l10n.yaml | gen-l10n config, en/hi templates |
lib/l10n/app_en.arb, app_hi.arb | shipped locales |
lib/l10n/app_ta.arb … bn | (planned) as catalogs ship server-side |
lib/i18n/key_map.dart | dotted → camelCase map |
lib/i18n/catalog_cubit.dart | fetch/cache/refresh (13 §2) |
lib/i18n/remote_lookup.dart | ARB→remote→key fallback |
01 — Product Overview (Leave Module)
- 1. What the module is
- 2. Core domain facts (from source)
- 3. Who uses it (roles from
role.schema.ts:8-65) - 4. Scope in / out
- 5. PRD native-app exclusion (flagged)
- 6. Client surface (screens) — see 05/06
Employee leave requests, balance tracking, and substitute-teacher assignment. Derived from
src/modules/leave/**(controller, service, schemas, DTOs, events, repositories),src/modules/rbac/schemas/role.schema.ts,src/modules/rbac/permissions.constants.ts,src/modules/users/schemas/user.schema.ts,studylyon-blueprint/03-Database/COLLECTIONS.md,studylyon-blueprint/04-Modules/Attendance.md, anddocs/IMPLEMENTATION_PLAN.md. Nothing here is invented; plan-only capability is marked(planned), client-only or roadmap-only(forward-looking), analytics(proposed). Shared platform context: 00-shared/01.
1. What the module is
The Leave module lets an authenticated user of a tenant request time off, an
org admin decide (approve/reject) those requests, and admins assign a
substitute teacher for an approved absence. Balances are computed live
from leave types plus approved requests — there is no stored balance
collection (leave.service.ts:87-88 — "live-computed balance from leave
types + approved requests; no stored balance collection to drift from ground
truth").
The blueprint anticipated this module but does not specify it: 04-Modules/ has
no Leave doc (only Attendance.md:58 lists "Leave" as an attendance status
value), and COLLECTIONS.md:3643 places leave_requests under "27. Future
Collections" → Human Resources. The implementation exists and is ahead of the
blueprint; docs/IMPLEMENTATION_PLAN.md:121-164 (Phase 2.2, "Leave & Substitution
Module", 7.5 days) is the authoritative plan.
2. Core domain facts (from source)
- Request model —
schemas/leave-request.schema.ts:16-48:userId(ref User, required),leaveTypeId(ref LeaveType, required),startDate,endDate(required Date),daysRequested(number,min: 1, server-computed),reason?,status(defaultpending),decidedBy?,decidedAt?,decisionNote?. Collectionleave_requests. Indexes(tenantId, userId, startDate)and(tenantId, status)(:52-53). - Status enum —
leave-request.schema.ts:7-12:pending | approved | rejected | cancelled. Note:cancelledis defined but no cancel endpoint exists — the status is unreachable today (gap). - Leave type model —
schemas/leave-type.schema.ts:9-25:code,name,daysPerYear(min: 0),carryForward(default false),maxCarryForward?,isDefault. Unique(tenantId, code)(:29). - Default types —
leave.service.ts:30-64:CLCasual 12 (no carry),SLSick 10 (carry, max 30),ELEarned 15 (carry, max 60),MLMaternity 180,PLPaternity 15. Seeded lazily and idempotently on first access viabulkWrite$setOnInsertupserts (ensureDefaultTypes,leave.service.ts:299-306). - Balance math —
leave.service.ts:89-124: carried forward =carryForward ? min(maxCarryForward, max(0, daysPerYear − usedPrevYear)) : 0(:107-112);daysRemaining = daysPerYear + carriedForward − daysUsed(:121). Balances use calendar years, lookback to Jan 1 of the previous year (:92-97). - Day counting —
leave.service.ts:310-312:countDaysis an inclusive calendar-day count ((end − start)/86 400 000 + 1); weekends and holidays are counted as leave days (ponytail comment: "switch to working-day count if the school policy needs it"). - Approval semantics —
leave.service.ts:171-212: onlypendingrequests can be decided (:175-178, else 409); a user cannot decide their own request (:179-180, 409); approval re-checks live balance and rejects with 409 "Insufficient leave balance." whendaysRemaining < daysRequested(:183-190). Decision writesstatus,decidedBy,decidedAt,decisionNote(:195-200) and emitsLeaveApproved/LeaveRejected. - Substitutions —
schemas/substitution.schema.ts:14-47:leaveRequestId,absentTeacherId,substituteTeacherId,classId,subjectId,date,startTime,endTime,status(assigned | completed | cancelled,:7-11),notes?. Only approved requests can get a substitution (leave.service.ts:227-230, 409); the requester must have a Teacher record (:232-238, 404); time-slot clash for the substitute is rejected (:241-250, 409,timeOverlaps:314-321). - Scope rules — non-admins see only their own requests
(
leave.service.ts:166-167); org admins see all, optionally filtered byuserId(:163-167). Requester identity always comes from the tenant context, never the body (:138).
3. Who uses it (roles from role.schema.ts:8-65)
| Role | In leave module |
|---|---|
org_admin (priority 100, ALL_PERMISSIONS role.schema.ts:22-24) | Requests, lists all, decides, creates types, assigns substitutions |
teacher (priority 50, role.schema.ts:26-32) | Requests leave; receives substitutions (needs a Teacher record) |
staff (priority 40, role.schema.ts:33-40) | Can request leave (any authenticated user can) |
student / parent | No leave surface; schema allows but nothing gates it (gap) |
No leave.* permissions exist — permissions.constants.ts:1-97 contains no
leave entries, and leave endpoints carry only JwtAuthGuard
(leave.controller.ts:27), with the org-admin check done inline via
ctx.roles (leave.service.ts:163). This is a documented security gap.
4. Scope in / out
| In scope (implemented) | Out of scope / gaps |
|---|---|
Create request (POST /leave/requests) | Cancel request (cancelled status unreachable — no endpoint) |
List requests (own / all for admin, status+userId filters) | Edit/withdraw a pending request |
| Approve / reject (balance-checked) | leave.* RBAC permissions (permissions.constants.ts has none) |
| Live-computed balance | Stored leave-balance.schema.ts — (planned) (IMPLEMENTATION_PLAN.md:130, not in code) |
| Leave types CRUD (create + list; seeded defaults) | Update/delete leave type |
| Substitutions (assign, list for teacher, clash check) | Substitution accept/decline by substitute, status transitions completed/cancelled |
| Calendar (approved only, month range) | Working-day counting (weekends/holidays) — see leave.service.ts:310-312 |
Events: LeaveRequested, LeaveApproved/Rejected, SubstitutionAssigned (events/leave-events.ts:1-26) | Substitution → Timetable entry + notifications — (planned) (IMPLEMENTATION_PLAN.md:163) |
5. PRD native-app exclusion (flagged)
Per 00-shared/01 §9: PRODUCT_REQUIREMENTS_DOCUMENT.md:144 puts native mobile
apps out of Phase 1 scope (roadmap Phase 3 plans a read-only companion). The
decision recorded with the product owner is that these docs specify a
full-featured Flutter client now, to the complete leave API surface; any
conflict with the web-first roadmap resolves in favor of these docs unless the
roadmap is amended.
6. Client surface (screens) — see 05/06
Request form · My requests list · Approvals queue (org admin) · Balance card · Leave types admin (org admin) · Substitutions (list + assign) · Calendar.
02 — User Personas (Leave Module)
- Legend
- 1. The Teacher — "Sneha, Grade 8 Science"
- 2. The Org Admin / HR — "Ramesh, Administrator"
- 3. The Substitute Teacher — "Imran, Part-time Relief"
- 4. Staff (non-teaching) — "Priya, Office Coordinator"
- 5. System actors (non-human)
- 6. Persona priority
Derived from
role.schema.ts:8-65(system roles),users/schemas/user.schema.ts:14-79, and the leave service semantics (leave.service.ts). Behaviors that the API enforces are cited; anything else is product expectation(proposed).
Legend
P = permission/authority from source · F = friction/behavior note.
1. The Teacher — "Sneha, Grade 8 Science"
| Field | Detail |
|---|---|
| Role | teacher (role.schema.ts:26-32) — permissions student.read, attendance.mark, attendance.edit |
| Context | Full-time faculty; 30+ contact hours/week; mobile-first; marks attendance in app daily |
| Leave needs | Sick days, casual time off, planned earned leave; must find a substitute before admin approves |
| Key actions | Request leave (type, dates, reason); check balance before asking; watch request status; see substitutions assigned for their own classes |
| API path | POST /leave/requests (leave.controller.ts:32-36); GET /leave/requests (own only, leave.service.ts:166-167); GET /leave/balance/:userId (:55-59) |
| Frustrations | Approvals that stall; balance surprises (weekend days count, leave.service.ts:310-312); no edit/withdraw path (gap) |
Success metric (proposed) | Request-to-decision median < 24 h; zero requests rejected for "insufficient balance" after the user checked balance |
2. The Org Admin / HR — "Ramesh, Administrator"
| Field | Detail |
|---|---|
| Role | org_admin — ALL_PERMISSIONS (role.schema.ts:18-24); detected inline in service via ctx.roles.includes('org_admin') (leave.service.ts:163) |
| Context | Runs the institution's staffing; approves or rejects leave; configures leave types; assigns substitutes |
| Key actions | Approvals queue (all requests, filter by status/userId, leave.controller.ts:38-47); decide with optional note (PATCH /leave/requests/:id/approve, :49-53); create leave types (POST /leave/types, :61-65); assign substitutes (POST /leave/substitutions, :73-77); view calendar of approved leave (GET /leave/calendar, :85-91) |
| Decision rules enforced by API | Only pending can be decided (leave.service.ts:175-178); cannot approve own request (:179-180); approval blocked at 409 if balance insufficient (:188-189) |
| Frustrations | Self-approval impossible by design (must delegate); no batch decisions; no leave-type edit/delete |
Success metric (proposed) | Approvals resolved same-day; substitution assigned within 24 h of approval for multi-day leave |
3. The Substitute Teacher — "Imran, Part-time Relief"
| Field | Detail |
|---|---|
| Role | teacher with a Teacher record (teacherRepo.findOne, leave.service.ts:232-238) |
| Context | Covers classes for absent teachers; paid per covered slot |
| Key actions | View assigned substitutions: GET /leave/substitutions/teacher/:id (leave.controller.ts:79-83) — list sorted by date ascending (leave.service.ts:275-280) |
| API constraints | Admin assigns; substitute does not accept/decline (no endpoint); slot clash is rejected at assignment time (leave.service.ts:241-250) |
| Frustrations | No accept/decline flow; no conflict visibility beyond admin checks; substitution status is stuck at assigned (no completed/cancelled transition in service) |
Success metric (proposed) | Substitution discovery < 30 s after open; no-show rate tracked via Timetable (planned, IMPLEMENTATION_PLAN.md:163) |
4. Staff (non-teaching) — "Priya, Office Coordinator"
| Field | Detail |
|---|---|
| Role | staff (role.schema.ts:33-40, permission student.read) |
| Context | Office staff; needs casual/sick leave like teachers; no substitution mechanics apply (no Teacher record → 404 on substitution, leave.service.ts:232-238) |
| Key actions | Request leave; check balance; track status — same endpoints as Teacher |
| Note | Any authenticated user can create a request (userId from tenant context, leave.service.ts:138); nothing in code restricts leave to staff/teachers (gap: student/parent roles are not excluded) |
5. System actors (non-human)
| Actor | Behavior |
|---|---|
| Event bus | LeaveRequested, LeaveApproved/LeaveRejected, SubstitutionAssigned payloads (events/leave-events.ts:1-26) → notification workers (planned) |
| Audit log | All writes carry createdBy/updatedBy via BaseSchema; decision writes decidedBy, decidedAt, decisionNote (leave-request.schema.ts:41-48) |
Timetable (planned) | Substitution creates a Timetable entry + notifies substitute and students (IMPLEMENTATION_PLAN.md:163) |
6. Persona priority
- Teacher (highest volume — requests + balance)
- Org Admin (decisions + substitutions)
- Substitute teacher
- Staff
03 — User Journey (Leave Module)
- Journey 1 — Teacher requests casual leave (happy path)
- Journey 2 — Admin assigns a substitute (post-approval)
- Journey 3 — Teacher checks coverage before requesting
- Journey 4 — Rejection and retry
- Journey 5 — Admin configures leave types
(org admin only) - Journey 6 — Edge: leave overlaps and back-dating
- Journey map (condensed)
- Metrics
(proposed)
End-to-end flows mapped to the exact endpoints they exercise. API-enforced branches are cited to source;
(planned)=IMPLEMENTATION_PLAN.md:121-164;(proposed)= product expectation.
Journey 1 — Teacher requests casual leave (happy path)
- Open Balance (
GET /leave/balance/:me—leave.controller.ts:55-59) → seedaysRemainingper type (leave.service.ts:89-124). - Open Request form → pick leave type (
GET /leave/types,:67-71; defaults seeded lazilyleave.service.ts:299-306), start/end dates, reason. - Submit
POST /leave/requests(:32-36) → server computesdaysRequested(leave.service.ts:136), stampsuserIdfrom token (:138), statuspending(:144), emitsLeaveRequested(:146-154).- 400 if
endDate < startDate(:133-134); 404 if leave type unknown (:129).
- 400 if
- Request appears in My requests (
GET /leave/requests— own only,leave.service.ts:166-167; sortedcreatedAtdesc:168), badgepending. - Org admin opens Approvals queue (
GET /leave/requests?status=pending,:38-47), decidesPATCH /leave/requests/:id/approve(:49-53).- 409 if already decided (
leave.service.ts:175-178), 409 if self-decision (:179-180), 409 if balance insufficient (:188-189).
- 409 if already decided (
- Teacher sees status flip to
approved(eventLeaveApproved,:210) → balance now reflects deduction on next fetch.
Journey 2 — Admin assigns a substitute (post-approval)
- From an approved request, admin opens Assign substitution form.
POST /leave/substitutions(leave.controller.ts:73-77) withleaveRequestId, substituteTeacherId, classId, subjectId, date, startTime, endTime, notes?.- 409 if request not
approved(leave.service.ts:227-230). - 404 if requester has no Teacher record (
:232-238). - 409 if substitute already booked in the slot (
:241-250).
- 409 if request not
- Substitution shows in substitute's My substitutions
(
GET /leave/substitutions/teacher/:id,:79-83; sorted by date:275-280). (planned)Timetable entry created + notifications to substitute and students (IMPLEMENTATION_PLAN.md:163).
Journey 3 — Teacher checks coverage before requesting
- Open Calendar (
GET /leave/calendar?from&to,leave.controller.ts:85-91) → approved requests overlapping the range (leave.service.ts:282-296); default = current month (:283-287). - Decide dates avoiding collisions with colleagues (calendar is
tenant-scoped via
BaseRepository, not filtered by user).
Journey 4 — Rejection and retry
- Admin rejects with note (
PATCH /leave/requests/:id/approve, actionreject,leave-decision.dto.ts:4-7) →decisionNotestored (leave.service.ts:199), eventLeaveRejected(:210). - Teacher sees
rejected+ note in My requests. - Teacher edits dates in a new request and resubmits (no edit/retry of the old request — no PATCH-request endpoint; gap).
(planned)notification of the decision reaches the teacher via the event bus (LeaveDecidedEvent,events/leave-events.ts:10-18).
Journey 5 — Admin configures leave types (org admin only)
- Open Leave types → list (
GET /leave/types,:67-71), defaults always present (ensureDefaultTypes,leave.service.ts:299-306). - Create custom type
POST /leave/types(:61-65) —code, name, daysPerYear, carryForward?, maxCarryForward?(create-leave-type.dto.ts:4-28). Unique(tenantId, code)(leave-type.schema.ts:29) → 11000 duplicate-key error surfaces as a conflict (no dedicated handler in service — gap). - Balance screen now includes the new type automatically (computed from
types,
leave.service.ts:89-124).
Journey 6 — Edge: leave overlaps and back-dating
- No same-user overlap validation exists at create time
(
createRequest,leave.service.ts:126-156); two overlappingpendingrequests can both exist. QA treats this as a known gap (see 14_QA_Checklist §2); a future server rule or client-side warning(forward-looking). - Back-dated requests are allowed — no rule prevents
startDatein the past.
Journey map (condensed)
[Balance check] → [Request] → pending → [Admin queue] → approve → [Calendar/status]
↘ reject → note → [Retry as new request]
[Approved] → [Assign substitution] → [Substitute sees assignment] → (planned) timetable+notify
Metrics (proposed)
Request → decision median; reject rate by reason; substitution coverage of approved leave; balance-sufficiency collisions.
04 — Information Architecture (Leave Module)
- 1. Navigation tree
- 2. Role gating (from source)
- 3. Content types
- 4. Screen-to-data map
- 5. Known IA gaps (from source)
- 6. Entry points
App-level navigation pattern per 00-shared/05 (global IA). Leave is a top-level workspace reachable from the home grid; org-admin surfaces nest under the same workspace. Routes are client-side names
(forward-looking)until the Flutter client lands; the API surface is authoritative (leave.controller.ts:25-28, tagleave).
1. Navigation tree
Home
└── Leave workspace (/leave)
├── My Requests (/leave/requests) — every user
│ ├── Request Detail (/leave/requests/:id) — read-only for requester
│ └── New Request (/leave/requests/new) — form
├── My Balance (/leave/balance) — every user
├── Leave Calendar (/leave/calendar) — every user (approved only)
└── [Org Admin only]
├── Approvals Queue (/leave/approvals) — status=pending default
├── Substitutions (/leave/substitutions) — list + assign sheet
└── Leave Types (/leave/types) — list + create sheet
2. Role gating (from source)
- Every user (any authenticated JWT,
leave.controller.ts:27): create request, list own requests, balance, types list, calendar. - org_admin only (inline check
leave.service.ts:163): see all requests (:167), decide (leave.controller.ts:49-53), create types (:61-65), assign substitutions (:73-77). - Substitute teacher:
GET /leave/substitutions/teacher/:id(leave.controller.ts:79-83) — client calls with the substitute's own teacher id. - No
leave.*permissions exist (permissions.constants.ts:1-97); do not invent client-side gates stronger than the API. See gap in §5.
3. Content types
| Content | Source | Owner |
|---|---|---|
| LeaveRequest | leave_requests collection (leave-request.schema.ts:14) | requester + admin |
| LeaveType | leave_types (leave-type.schema.ts:7) | org admin (defaults seeded) |
| Substitution | substitutions (substitution.schema.ts:13) | org admin |
| Balance | computed, no collection (leave.service.ts:87-88) | per user |
4. Screen-to-data map
| Screen | Data | Endpoint |
|---|---|---|
| My Requests | requests (own) | GET /leave/requests (leave.controller.ts:38-47) |
| New Request | types + post | GET /leave/types + POST /leave/requests (:67-71, :32-36) |
| Approvals Queue | requests (all) | GET /leave/requests?status= (:38-47) |
| My Balance | balance | GET /leave/balance/:userId (:55-59) |
| Calendar | approved | GET /leave/calendar?from&to (:85-91) |
| Substitutions | assigned | GET /leave/substitutions/teacher/:id (:79-83) |
| Leave Types | types | GET /leave/types (:67-71) |
5. Known IA gaps (from source)
- Cancel — status
cancelledexists (leave-request.schema.ts:11) but no endpoint can produce it → no cancel action anywhere in IA. - Request detail — no
GET /leave/requests/:id; the client must pass the whole object from the list (list is the only read surface). - RBAC — approvals UI must hide for non-admins by client role check;
the API would still enforce at
leave.service.ts:163-167(non-admin userId filter) and:179-180(self-decision). - Balance for other users —
GET /leave/balance/:userIdaccepts any id; no server check restricts it (gap — expose only admin/own in client). - Substitutions list for absent teacher — endpoint only lists by
substituteTeacherId(leave.service.ts:275-280); no "who covers me" list for the absent teacher (gap).
6. Entry points
- Home grid tile "Leave" (per
00-shared/05). - Notification tap → deep link to request detail
(planned)— driven byLeaveApproved/LeaveRejectedevents (events/leave-events.ts:10-18). - Calendar day tap → request detail sheet.
05 — Screen Inventory (Leave Module)
- Legend
- 1. Leave Balance Screen (
/leave/balance) - 2. Request Leave Screen (
/leave/requests/new) - 3. My Requests List (
/leave/requests) - 4. Request Detail Sheet (
/leave/requests/:id) - 5. Approvals Queue (
/leave/approvals) — org admin - 6. Substitutions List (
/leave/substitutions) — substitute teacher - 7. Assign Substitution Sheet (
/leave/approvals/assign) — org admin - 8. Leave Types List + Create (
/leave/types) — org admin - 9. Leave Calendar (
/leave/calendar) - 10. Notifications
(planned) - Shared components used
- Analytics events (proposed)
Every screen of the Leave module, its intent, route, composition, states, permissions, and data source. Authoritative shared components in 00-shared/03; this file enumerates which ones each screen uses with module specifics. Legend matches
design-docs/auth/05.
Legend
States = idle / loading / success / empty / error(offline, server, conflict) /
permission. Analytics follow {module}.{screen}.{action} (proposed).
1. Leave Balance Screen (/leave/balance)
| Field | Detail |
|---|---|
| Purpose | Show daysRemaining per leave type for the current user |
| Entry | home grid "Leave → Balance"; after a rejected request (contextual) |
| Source | GET /leave/balance/:userId (leave.controller.ts:55-59) → LeaveBalanceEntry[]: leaveTypeId, code, name, daysPerYear, carriedForward, daysUsed, daysRemaining (leave.service.ts:66-74) |
| Composition | AppCard per type: code badge, name, daysRemaining headline, daysPerYear, carriedForward, daysUsed sub-line; linear progress used/entitlement |
| States | loading (skeleton per card); empty (no types — server seeds defaults on first call, leave.service.ts:299-306, so practically unreachable); error (offline → cached copy + banner; 404 user → inline) |
| Refresh | pull-to-refresh (RefreshIndicator) |
| Analytics | leave.balance.open, leave.balance.refresh |
| Permission | any authenticated user; client hides for viewing others' balance (API does not restrict — gap, leave.controller.ts:55-59) |
| Notes | Balance is live-computed (leave.service.ts:87-88); never cache > 1 session. Weekend/holiday days count as used days (:310-312) — surface a caption on the card |
2. Request Leave Screen (/leave/requests/new)
| Field | Detail |
|---|---|
| Purpose | Create a leave request |
| Entry | My Requests FAB; Balance card action |
| Source | GET /leave/types (leave.controller.ts:67-71) for the picker; submit POST /leave/requests (:32-36) |
| Form | leave type (required, dropdown from types), startDate (required), endDate (required), reason (optional, multiline) — contract per create-leave-request.dto.ts:4-21; daysRequested is server-computed (not in DTO) |
| Primary CTA | "Submit request" |
| States | loading (types + submit); validation (client: endDate ≥ startDate; mirror of leave.service.ts:133-134); error: 404 type (reload types), 409/offline |
| Success | request created pending → toast → detail/list |
| Analytics | leave.request.open, leave.request.submit, leave.request.success, leave.request.failure(code) |
| Permission | any authenticated user (id from token, leave.service.ts:138) |
| Notes | After submit show computed days + "pending" hint; warn on dates that include weekends (server counts them, :310-312) |
3. My Requests List (/leave/requests)
| Field | Detail |
|---|---|
| Purpose | List the user's own requests, newest first |
| Source | GET /leave/requests — non-admins are forced to own userId (leave.service.ts:166-167); sorted createdAt desc (:168) |
| Composition | AppListTile per request: type code badge, date range, daysRequested, status chip (pending/approved/rejected), decision note line when present |
| Filters | status client-side or server ?status= (leave.controller.ts:40) |
| States | loading / empty ("No leave requests yet" + CTA) / error / offline (cached) |
| Refresh | pull-to-refresh |
| Analytics | leave.requests.list, leave.requests.filter(status) |
| Permission | any authenticated user |
4. Request Detail Sheet (/leave/requests/:id)
| Field | Detail |
|---|---|
| Purpose | Read-only detail of one request (no GET /:id — data passed from list; gap noted in 04 §5) |
| Content | type, dates, days, reason, status, decidedBy, decidedAt, decisionNote (leave-request.schema.ts:16-48) |
| Actions | none for requester (no cancel/edit endpoint); admin (if approved) → "Assign substitution" shortcut |
| States | as list row, expanded |
5. Approvals Queue (/leave/approvals) — org admin
| Field | Detail |
|---|---|
| Purpose | Review and decide all tenant requests |
| Source | GET /leave/requests?status=pending (admin sees all, leave.service.ts:163-167); optional ?userId= (leave.controller.ts:41-44) |
| Composition | filter chips (pending default, approved, rejected); list of request tiles with requester name (needs users lookup — no populate in service, gap), dates, days, reason |
| Row action | AppMenu → "Approve" / "Reject (add note)" → PATCH /leave/requests/:id/approve with {action, note?} (leave-decision.dto.ts:9-17) |
| States | per row: deciding (button spinner); error: 409 already decided (leave.service.ts:175-178) → refresh list; 409 insufficient balance (:188-189) → inline "insufficient balance" chip; 409 self (:179-180) → hide own requests from queue in client |
| Empty | "No pending requests" |
| Analytics | leave.approvals.open, leave.approvals.decide(action) |
| Permission | org admin only (client-side gate; server allows any non-self decision — gap, no RBAC guard, leave.controller.ts:27) |
6. Substitutions List (/leave/substitutions) — substitute teacher
| Field | Detail |
|---|---|
| Purpose | Show assignments for the current teacher |
| Source | GET /leave/substitutions/teacher/:id (leave.controller.ts:79-83); sorted date asc (leave.service.ts:275-280) |
| Composition | date-grouped AppListTiles: class, subject, start–end time, notes; status chip assigned (only reachable state from service) |
| States | loading / empty ("No substitutions assigned") / error |
| Refresh | pull-to-refresh |
| Analytics | leave.substitutions.list |
7. Assign Substitution Sheet (/leave/approvals/assign) — org admin
| Field | Detail |
|---|---|
| Purpose | Assign a substitute teacher for an approved request |
| Source | POST /leave/substitutions (leave.controller.ts:73-77); needs teacher list (teachers module), class/subject pickers |
| Form | leaveRequestId (from context), substituteTeacherId, classId, subjectId, date, startTime, endTime, notes? (assign-substitution.dto.ts:4-37) |
| States | error: 409 not approved (leave.service.ts:227-230), 404 no teacher record (:232-238), 409 time clash (:241-250) → inline "already assigned in this slot" |
| Permission | org admin |
8. Leave Types List + Create (/leave/types) — org admin
| Field | Detail |
|---|---|
| Purpose | View seeded/custom types; create new |
| Source | GET /leave/types (:67-71); POST /leave/types (:61-65) |
| Form | code, name, daysPerYear, carryForward?, maxCarryForward? (create-leave-type.dto.ts:4-28) |
| States | duplicate code → MongoDB unique (tenantId, code) (leave-type.schema.ts:29) → client maps 11000 to "code already exists" (no server mapper — gap) |
| Permission | org admin |
9. Leave Calendar (/leave/calendar)
| Field | Detail |
|---|---|
| Purpose | Month grid of approved leave (tenant-wide) |
| Source | GET /leave/calendar?from&to (leave.controller.ts:85-91) — approved only, overlap query (leave.service.ts:282-296); default current month (:283-287) |
| Composition | month pager; day cells with count/avatars of who is out; tap → detail sheet |
| States | loading / empty / error |
| Notes | Client should restrict from/to to a month window to match server default semantics |
10. Notifications (planned)
Decision pushed to requester via LeaveApproved/LeaveRejected events
(events/leave-events.ts:10-18) → Notifications module; substitution
notification to substitute + students (planned, IMPLEMENTATION_PLAN.md:163).
Shared components used
AppTextField, AppButton, AppSnackbar, AppCard, AppListTile,
AppBottomSheet, AppDialog, AppMenu, AppSkeleton, AppEmptyState,
AppOfflineBanner, AppFAB, AppChips, AppBadge, AppAvatar, DatePicker,
SegmentedButton. Module-specific: StatusChip, BalanceCard,
LeaveTypeCard, SubstitutionTile, ApprovalActionSheet — see
07_Component_Library.md.
Analytics events (proposed)
leave.balance.open|refresh, leave.request.open|submit|success|failure,
leave.requests.list|filter, leave.approvals.open|decide,
leave.substitutions.list, leave.calendar.open|month, leave.types.list|create.
06 — Screen Specifications (Leave Module)
- 1. Leave Balance Screen
- 2. Request Leave Screen
- 3. My Requests List
- 4. Request Detail Sheet
- 5. Approvals Queue
- 6. Substitutions List (substitute teacher)
- 7. Assign Substitution Sheet (org admin)
- 8. Leave Types (org admin)
- 9. Leave Calendar
- Cross-screen rules
Full behavioral specification per screen: layout, states, interactions, accessibility, motion, adaptive behavior, and edge cases — all anchored to the API contract. Shared motion/a11y baselines: 00-shared/08, 00-shared/09. This is the largest design doc in the package; every rule maps to source.
1. Leave Balance Screen
1.1 Layout (mobile, 360 dp reference)
AppBar: "My Leave Balance" [refresh]
────────────────────────────
[Summary strip] Total remaining (sum of daysRemaining) (proposed)
────────────────────────────
LeaveTypeCard × N (01 = product overview defaults)
[CL] Casual Leave 12 days/year
8 remaining ────── 4 used · 0 carried forward
────────────────────────────
Caption: "Weekends and holidays count as leave days."
1.2 Data contract
GET /leave/balance/:userId → 200 array of
{ leaveTypeId, code, name, daysPerYear, carriedForward, daysUsed, daysRemaining }
(leave.service.ts:66-74,89-124). Sorted as returned (types sorted by
repository default; listTypes uses {code:1} leave.service.ts:216).
1.3 States
| State | Render |
|---|---|
| loading | skeleton AppSkeleton per expected card (default types known: CL, SL, EL, ML, PL — leave.service.ts:30-64) |
| success | cards + progress bars; daysRemaining = daysPerYear + carriedForward − daysUsed (leave.service.ts:121) |
| empty | should not occur (defaults seeded :299-306); fallback AppEmptyState "No leave types configured" |
| error 404 | "User not found" inline (API does not validate user — gap) |
| offline | cached snapshot + AppOfflineBanner; data freshness ≤ 1 session (live-computed :87-88) |
| permission | n/a server-side; client shows own only |
1.4 Interactions & motion
- Pull-to-refresh → refetch;
m-fastopacity swap on refresh (per00-shared/08). - Progress bar animates
m-baseon data arrival. - Tap card → no navigation (or type detail popover
(proposed)).
1.5 Edge cases
- Type with
carryForward=false→ hide "carried forward" line. daysRemainingnegative (over-use impossible by server check,:188-189, but back-dated requests could still makedaysUsedexceed entitlement across months) → clamp progress bar at 0, show "0 remaining" in error color.- Year boundary: balance uses calendar year + previous-year lookback
(
:92-97); on Jan 1 numbers reset — client must not cache across days.
1.6 Accessibility
- Each card:
Semantics(label: "Casual Leave, 8 of 12 days remaining"). - Progress bars expose
valuevia semantics; color is never the only indicator (add percentage text). - Contrast: status colors per
00-shared/02.
2. Request Leave Screen
2.1 Layout
AppBar: "New Leave Request"
[Leave type] ▾ dropdown (from GET /leave/types)
[Start date] date picker
[End date] date picker
[Days] read-only preview: "3 days" (client counts inclusively,
mirror of leave.service.ts:310-312)
[Reason] multiline, optional
────────────────────────────
[Submit request] (filled, fullWidth)
2.2 Form contract (create-leave-request.dto.ts:4-21)
| Field | Type | Rules (source) |
|---|---|---|
leaveTypeId | mongoId | required (:6-7) |
startDate | ISO date string | required (:9-11) |
endDate | ISO date string | required (:13-15); must be ≥ startDate (leave.service.ts:133-134, 400) |
reason | string | optional (:17-20) |
userId and daysRequested come from the server (leave.service.ts:138,136).
2.3 States & validation
| State | Behavior |
|---|---|
| loading types | dropdown disabled with skeleton |
| client validation | end < start → inline error (mirror 400); empty type/date → required errors; daysRequested preview updates live |
| submit | button spinner; disable resubmit |
| 404 | "Leave type not found" → refetch types |
| offline | queued write with idempotency (per 00-shared/01 §8) or block-and-inform — recommend block; balance checks need live data |
| success | toast "Request submitted (pending)"; pop to My Requests |
2.4 Edge cases
- Weekend span: show computed days + caption "includes weekends" (server
counts calendar days,
:310-312). - Overlapping own pending request: no server rule (gap — see
14 §2); client warns "You have a pending request in this range" (best-effort scan of cached own requests). - Past start date: allowed by API; client optional warning.
- Zero-day range: impossible (
endDate ≥ startDate→ ≥ 1 day). - No types: server seeds defaults on first access (
:299-306); if list still empty (rare) showAppEmptyState.
2.5 Accessibility
- Date fields: labels +
Semanticshint "opens date picker"; keyboard entry allowed (yyyy-mm-dd) for power users. - Errors in live region; focus first invalid field.
- min tap target 48 dp for pickers/CTA.
3. My Requests List
3.1 Layout
AppBar: "My Leave Requests" [FAB +]
[Filter chips] All | Pending | Approved | Rejected
────────────────────────────
RequestTile × N
[CL] 12 Aug – 14 Aug · 3 days [pending chip]
"Family function" (reason, 2-line ellipsis)
2 days ago · note: "Approved by Ramesh" (when decidedBy)
3.2 Data contract
GET /leave/requests (own enforced: leave.service.ts:166-167; sorted
createdAt desc :168). Optional ?status= (leave.controller.ts:40).
?userId= is admin-only (:167); non-admin client never sends it.
3.3 States & interactions
| Item | Spec |
|---|---|
| filter chips | server-side refetch per status (AppChips single-select); keep All default |
| row | tap → detail sheet; swipe actions: none (no cancel endpoint) |
| empty | AppEmptyState "No leave requests yet" + CTA "Request leave" |
| pull-to-refresh | refetch current filter |
| offline | cached list + banner; decisions unavailable |
| badge | chip colors: pending → tertiary, approved → success, rejected → error (semantic tokens, 00-shared/02) |
3.4 Edge cases
- Request decided after row rendered → next fetch shows new status; no push
needed for MVP
(planned)notifications. reasonabsent → omit line.- Long reasons → 2-line ellipsis; full text in detail sheet.
4. Request Detail Sheet
Bottom sheet (or master-detail pane on tablet, 00-shared/05).
| Section | Content |
|---|---|
| header | type badge + status chip + daysRequested |
| body | dates, reason, timestamps (createdAt), decision block: decidedBy, decidedAt, decisionNote (leave-request.schema.ts:41-48) |
| actions | none (requester); admin on approved → "Assign substitution" |
Empty decisionNote → "No note provided" muted line.
5. Approvals Queue
5.1 Layout
AppBar: "Leave Approvals" [filter: All | userId (staff picker)]
[Chips] Pending (default) | Approved | Rejected
────────────────────────────
ApprovalTile × N
[SL] Sneha R. · 12 Aug – 14 Aug · 3 days [pending chip]
"Fever, doctor's note attached" (reason)
Balance: 9/10 remaining (contextual, proposed)
[Approve] [Reject]
5.2 Data contract
GET /leave/requests?status=pending (+ optional ?userId=). Admin path
confirmed at leave.service.ts:163-167 via ctx.roles.includes('org_admin').
5.3 Decision flow
- Tap "Reject" →
AppBottomSheetwith note field (optional) + confirm. PATCH /leave/requests/:id/approvebody{action: "approve"|"reject", note?}(leave-decision.dto.ts:9-17).- Response 200 → chip updates; remove row from
pendingfilter. - Errors:
- 409 already
approved/rejected(leave.service.ts:175-178) → toast "Already decided" + refetch list. - 409 insufficient balance (
:188-189) → inline chip "Insufficient balance" on the row; row stays pending; admin may reject with note. - 409 self-decision (
:179-180) → client hides own requests in this screen (server would reject anyway).
- 409 already
- Optimistic update: flip chip to "processing" (neutral), rollback on error —
decision is money-adjacent (balance), so no silent optimistic success;
wait for 200 (see
14 §5race notes).
5.4 Empty & edge
- "No pending requests" with confetti-free calm copy.
- Reject without note allowed (
note?optional,leave-decision.dto.ts:14-17). - Requester names: API returns
userIdonly — client resolves names via users module(forward-looking); until then show short id.
6. Substitutions List (substitute teacher)
AppBar: "My Substitutions"
[Date group header] Mon 18 Aug
[Physics 9A] 08:00–09:00 · for Sneha R. [assigned chip]
notes: "Cover chapter 4"
- Source:
GET /leave/substitutions/teacher/:id(leave.controller.ts:79-83), sorteddateasc (leave.service.ts:275-280); group client-side by date. - States: loading / empty "No substitutions assigned" / error.
- Status chip:
assignedis the only state the service ever sets (leave.service.ts:261);completed|cancelledare schema-defined but unreachable (substitution.schema.ts:7-11) — render onlyassignedfor now. - Refresh: pull-to-refresh; offline → cached + banner.
7. Assign Substitution Sheet (org admin)
Form fields (assign-substitution.dto.ts:4-37):
| Field | Control | Rules |
|---|---|---|
substituteTeacherId | teacher picker (search) | required :9-11 |
classId | class picker | required :13-15 |
subjectId | subject picker | required :17-19 |
date | date picker | required :21-23; must fall within approved leave range (client hint; server does not check — gap) |
startTime/endTime | time pickers (HH:mm) | required :25-31; start < end (client; server clash check implies it :246) |
notes | multiline | optional :33-36 |
Submit → POST /leave/substitutions → 201/200 → toast → open Substitutions.
Errors: 409 not approved (leave.service.ts:227-230), 404 no teacher
record for requester (:232-238), 409 time clash (:241-250, message
"Substitute teacher already assigned in this time slot."). Clash message shown
inline under the time fields.
8. Leave Types (org admin)
8.1 List
GET /leave/types (leave.controller.ts:67-71) — cards: code badge, name,
daysPerYear, carry-forward chips (carryForward + maxCarryForward).
Default types present on first access (leave.service.ts:299-306).
8.2 Create sheet
Fields per create-leave-type.dto.ts:4-28: code (uppercase, 2-4 chars,
hint), name, daysPerYear (@Min(1) :14-16), carryForward switch
(:18-21), maxCarryForward (shown only when carry on, :23-27).
Submit → POST /leave/types; duplicate (tenantId, code) → Mongo
E11000 (unique index leave-type.schema.ts:29) → map to "Code already
exists" inline (server has no mapper — gap).
9. Leave Calendar
AppBar: "Leave Calendar" [‹] Aug 2026 [›]
Mo Tu We Th Fr Sa Su
3 4 5 [6×2] 7 ...
────────────────────────────
Day cell: up to 3 avatar chips + "+2" overflow; tap → list of
approved requests that day (client-side filter of month payload).
- Source:
GET /leave/calendar?from=&to=— approved only, range overlap (leave.service.ts:282-296); default month = server's current month (:283-287). Client always sends explicitfrom/tofor the visible month. - Paging: prev/next month triggers refetch.
- Colors: day with leave → primary-tinted cell; today → outlined.
- Offline: cache last month; show banner.
Cross-screen rules
- All timestamps render in user
timezone(user.schema.ts:52-53) with server dates parsed as UTC. - Every list screen: skeleton → content/empty; errors never blank.
- Envelope: unwrap
{success, message, data, timestamp, requestId}per00-shared/07.
07 — Component Library (Leave Module)
- 1.
StatusChip— leave request status - 2.
BalanceCard— one leave type balance row - 3.
RequestTile— list row for a request - 4.
SubstitutionTile— substitution list row - 5.
ApprovalActionSheet— decision UI - 6.
LeaveTypeCard— type row (types admin) - 7.
CalendarDayCell+MonthGrid - 8. Composition rules
- 9. Inventory vs shared
Module-specific widgets. Shared primitives (
AppCard,AppChips,AppBadge,AppListTile,AppButton,AppBottomSheet,AppMenu,AppSnackbar,AppEmptyState,AppSkeleton,DatePicker,TimePicker,SegmentedButton) are owned by 00-shared/03; this file only defines leave-specific compositions on top of them. Tokens: 00-shared/02.
1. StatusChip — leave request status
| Prop | Value |
|---|---|
| Input | status: 'pending' | 'approved' | 'rejected' | 'cancelled' |
| Render | small pill: dot + label (see 11_Design_System_Mapping for colors) |
| Statuses | pending (tertiary, filled dot), approved (success), rejected (error), cancelled (neutral/outlined — unreachable today, leave-request.schema.ts:11, rendered defensively) |
| Semantics | label: "Status: pending"; never color-only |
| Tests | unit: renders each status; snapshot |
2. BalanceCard — one leave type balance row
[CL] Casual Leave 8 / 12
├─ used 4 · carried 0
└─ [progress 66%]
| Prop | Value |
|---|---|
| Input | LeaveBalanceEntry (leave.service.ts:66-74) |
| Variants | carry-forward line shown only when carryForward |
| Behavior | progress = daysUsed / daysPerYear clamped 0–1; over-entitlement clamps bar at 1 with error color |
| a11y | combined Semantics label "Casual Leave, 8 of 12 days remaining" |
3. RequestTile — list row for a request
- Data:
LeaveRequestfields used —leaveTypeId(resolved to code via types cache),startDate,endDate,daysRequested,reason,status,decisionNote(leave-request.schema.ts:16-48). - Layout: leading
AppAvatar-style code badge, title<code> <range> · <days>d, subtitle reason (2-line), trailingStatusChip. - Slots:
onTap, optionaltrailingActions(admin approve/reject menu). - Variants:
ApprovalTile(adds requester line + balance hint(proposed)+ Approve/Reject buttons).
4. SubstitutionTile — substitution list row
- Data:
substitution.schema.ts:14-47—classId,subjectId,date,startTime,endTime,notes,status. - Layout: title
<subject> · <class>, subtitle<date> <startTime>–<endTime>, trailing status chip (onlyassignedreachable,leave.service.ts:261). - Resolve class/subject names via their modules' caches
(forward-looking).
5. ApprovalActionSheet — decision UI
- Opens from
ApprovalTilemenu or row action. - Segmented choice
approve | reject(leave-decision.dto.ts:4-7); when reject →AppTextFieldnote (optional,:14-17). - Confirm →
AppButton"Confirm approve/reject" → callback; caller executesPATCH /leave/requests/:id/approve. - Shows 409 error inline (already decided / insufficient balance /
self-decision,
leave.service.ts:175-189).
6. LeaveTypeCard — type row (types admin)
- Data:
leave-type.schema.ts:9-25—code,name,daysPerYear,carryForward,maxCarryForward,isDefault(default types badge "Default"). - Sub-line: "Carries forward up to 60 days" only when
carryForward.
7. CalendarDayCell + MonthGrid
- Cell: date number, up to 3 mini avatars,
+Noverflow; states: today (outlined), leave-day (tinted), selected (filled). - Tap → callback with date; parent shows day detail list.
- Data source is
GET /leave/calendarpayload (approved requests,leave.service.ts:282-296).
8. Composition rules
- Every component is stateless; state lives in Cubits (
13_State_Management.md). - No business logic in widgets (dates/counts computed in models/services).
- All components accept
themefromAppThemeonly; no hardcoded colors (tokens per00-shared/02). - Placeholder resolution (type code ↔ name, teacher id ↔ name) via injected lookup services, never in-widget.
9. Inventory vs shared
Shared (00-shared/03) | Leave-specific (this file) |
|---|---|
| AppCard, AppListTile, AppChips, AppBadge, AppButton, AppMenu, AppBottomSheet, AppSnackbar, AppEmptyState, AppSkeleton, DatePicker, TimePicker, SegmentedButton, RefreshIndicator | StatusChip, BalanceCard, RequestTile/ApprovalTile, SubstitutionTile, ApprovalActionSheet, LeaveTypeCard, CalendarDayCell, MonthGrid |
08 — Form Specifications (Leave Module)
- 1. Request Leave Form
- 2. Decision Form (ApprovalActionSheet)
- 3. Create Leave Type Form (org admin)
- 4. Assign Substitution Form (org admin)
- 5. Form system rules (all forms)
Every form the module exposes, its exact DTO contract, validation rules, error mapping, and submission behavior. All contracts are literal copies of the DTO classes; the client mirrors server rules and adds only what the server cannot express (calendar pickers, confirmations).
1. Request Leave Form
Contract: create-leave-request.dto.ts:4-21; submit POST /leave/requests
(leave.controller.ts:32-36).
| Field | Control | Required | Client validation | Server validation (source) |
|---|---|---|---|---|
leaveTypeId | dropdown (from GET /leave/types) | ✓ | non-empty | @IsMongoId() :6-7; type must exist → 404 leave.service.ts:129 |
startDate | date picker (no past restriction) | ✓ | non-empty, valid ISO | @IsDateString() :9-11 |
endDate | date picker | ✓ | ≥ startDate (mirror) | @IsDateString() :13-15; endDate < startDate → 400 leave.service.ts:133-134 |
reason | multiline (≤ 500 chars client) | – | – | @IsString() optional :17-20 |
Server adds (not client-sendable): userId from token (leave.service.ts:138),
daysRequested computed (:136), status: pending (:144).
Read-only preview: days count client-side with the same inclusive formula
(leave.service.ts:310-312) and label "includes weekends/holidays" when the
range contains any.
Error mapping
| HTTP | Source | UI |
|---|---|---|
| 400 | endDate must be on or after startDate. :134 | inline on endDate |
| 404 | Leave type not found. :129 | refetch types + retry |
| 401 | JWT guard leave.controller.ts:27 | session refresh |
| 409 | (no overlap rule exists today — gap) | client warning only |
Submit: disable while in-flight; success → toast + pop; failure → keep form values.
2. Decision Form (ApprovalActionSheet)
Contract: leave-decision.dto.ts:4-17; submit PATCH /leave/requests/:id/approve
(leave.controller.ts:49-53).
| Field | Control | Required | Notes |
|---|---|---|---|
action | segmented approve | reject | ✓ | @IsEnum(LeaveDecisionAction) :11-12; enum :4-7 |
note | multiline (≤ 500 client) | – | @IsOptional() @IsString() :14-17; stored as decisionNote leave.service.ts:199 |
Server rules surfaced in form:
- request must be
pendingelse 409 (leave.service.ts:175-178) → toast "Already decided" + refetch. - cannot decide own request → 409 (
:179-180) — client hides own rows. - approve with insufficient balance → 409 (
:188-189) → block approve, offer reject-with-note.
Confirmation: reject with note → single confirm dialog ("Reject this leave request?"). Approve → immediate (fast path).
3. Create Leave Type Form (org admin)
Contract: create-leave-type.dto.ts:4-28; submit POST /leave/types
(leave.controller.ts:61-65).
| Field | Control | Required | Server rule |
|---|---|---|---|
code | text, uppercase transform | ✓ | @IsString() :6-7; unique per tenant leave-type.schema.ts:29 |
name | text | ✓ | @IsString() :10-11 |
daysPerYear | number stepper | ✓ | @IsInt() @Min(1) :14-16 |
carryForward | switch | – | @IsBoolean() default false :18-21 |
maxCarryForward | number (visible only when carry on) | – | @IsInt() @Min(0) :23-27 |
Duplicate code: Mongo E11000 on unique index → client maps to inline "Code already exists" (no server mapper — gap).
Read-only note: isDefault types cannot be duplicated in practice (code
uniqueness); defaults appear in list automatically (leave.service.ts:299-306).
4. Assign Substitution Form (org admin)
Contract: assign-substitution.dto.ts:4-37; submit POST /leave/substitutions
(leave.controller.ts:73-77).
| Field | Control | Required | Server rule |
|---|---|---|---|
leaveRequestId | hidden (from context) | ✓ | @IsMongoId() :6-7; must exist 404 :225-226; must be approved 409 :227-230 |
substituteTeacherId | teacher search picker | ✓ | @IsMongoId() :10-11; clash check :241-250 |
classId | class picker | ✓ | @IsMongoId() :14-15 |
subjectId | subject picker | ✓ | @IsMongoId() :18-19 |
date | date picker | ✓ | @IsDateString() :22-23; should fall inside approved range (client hint only — gap) |
startTime | time picker HH:mm | ✓ | @IsString() :26-27 |
endTime | time picker HH:mm | ✓ | @IsString() :29-30; start < end (client; implied by timeOverlaps :314-321) |
notes | multiline | – | @IsOptional() @IsString() :34-36 |
Error mapping
| HTTP | Source | UI |
|---|---|---|
| 409 | Leave request must be approved before assigning a substitution. :228-229 | disable form; back to list |
| 404 | No teacher record found for the leave requester. :236-237 | toast, block |
| 409 | Substitute teacher already assigned in this time slot. :247-248 | inline under time fields |
5. Form system rules (all forms)
- Validate on submit + on-field-blur; errors in live regions; focus first invalid.
- Disable submit while loading; never double-submit (idempotency on POSTs is server-side concern — clients retry only on explicit user action).
- Offline: request/decision forms block (they mutate server state); read-only screens degrade to cache.
- All text via i18n keys (00-shared/09); dates localized per user locale.
09 — User Behaviour (Leave Module)
- 1. Requesting leave
- 2. Checking balance
- 3. Approving (org admin)
- 4. Substituting (substitute teacher)
- 5. Reading the calendar
- 6. Behavioural expectations
(proposed) - 7. Metrics
(proposed)
Observed and expected user behaviours around leave, mapped to what the API actually enforces. Behaviors are
(proposed)product expectations unless cited to source; metrics are analytics(proposed).
1. Requesting leave
- Frequency: teachers request a few times per term (sick + planned); admins configure once. High-trust, low-frequency writes.
- Pre-request ritual: users check balance first (
GET /leave/balance) because approval is balance-gated (leave.service.ts:188-189) — the client should surface balance inline on the request form(proposed)to collapse this step. - Date planning: users plan around weekends; the server counts them
(
leave.service.ts:310-312) — expect mismatch complaints; the request form shows the inclusive count up front (06 §2.4). - Overlap behavior: users may file overlapping pending requests (e.g.,
sick leave on top of planned leave). No server rule rejects this (gap —
createRequestleave.service.ts:126-156has no overlap check); a client warning on submit is the mitigation(proposed).
2. Checking balance
- Cadence: before each request; after each approval (deduction is
live-computed,
leave.service.ts:87-88— always fresh). - Mental model: users think "12 days/year" and forget carry-forward;
the card must visually separate
daysPerYear | carriedForward | daysUsed | daysRemaining(:66-74). - Year boundary: balance resets per calendar year with prev-year carry
(
:92-97,107-112) — Jan behavior surprises users; caption it.
3. Approving (org admin)
- Ritual: open queue, skim reasons, decide fast; self-approval is
impossible (
leave.service.ts:179-180) — admins delegate to a peer. - Balance surprise: approval of a request that exceeds balance 409s
(
:188-189); admin's mental model must be "reject or wait" — the UI offers reject-with-note as the fallback action. - Double-decision: second decision 409s (
:175-178) — UI refreshes and greys the row. - Batch desire: admins want batch approve
(proposed)— no server support (one PATCH per id,leave.controller.ts:49-53).
4. Substituting (substitute teacher)
- Discovery: substitute checks "My Substitutions" (
GET /leave/substitutions/teacher/:id,leave.controller.ts:79-83); no push today ((planned)notification,IMPLEMENTATION_PLAN.md:163). - Conflict avoidance: server rejects clashes at assign time
(
leave.service.ts:241-250); substitutes rely on admin accuracy — no accept/decline loop exists (gap). - No-show tracking:
completed/cancelledstates are schema-only (substitution.schema.ts:7-11); behavior tracking is(planned)via Timetable integration.
5. Reading the calendar
- Use: planning coverage, checking who is out before scheduling meetings —
calendar is approved-only (
leave.service.ts:290) and tenant-wide.
6. Behavioural expectations (proposed)
| Expectation | Design response |
|---|---|
| Users distrust stale balance | balance always refetched on screen open; RefreshIndicator; caption "live" |
| Fast approval | Approvals queue default filter pending; approve = 1 tap, reject = 2 taps max |
| Requesters check status often | status chips on list; (planned) push on decision event (events/leave-events.ts:10-18) |
| Admins never re-decide | after decision, row leaves pending view |
| Substitutes plan mornings | substitutions sorted by date asc (leave.service.ts:275-280), grouped by day |
7. Metrics (proposed)
leave.request.submit → decision time (median/P90); balance-check → submit
conversion; approval → substitution assignment rate; clash-rejection rate
(signal of poor coverage planning); reject-with-"insufficient balance" rate
(gap indicator for request-form balance preview).
10 — Interaction Specification (Leave Module)
- 1. Navigation & transitions
- 2. List screens (My Requests, Approvals, Substitutions)
- 3. Request form
- 4. Decision interaction (Approvals Queue)
- 5. Assign substitution sheet
- 6. Calendar
- 7. Feedback & haptics
- 8. Offline matrix
- 9. Accessibility interactions
Interaction patterns per screen: gestures, transitions, feedback, offline behavior, optimistic updates, and edge handling. Motion tokens (
m-fast,m-base), haptics, and offline policy per 00-shared/08; QA baseline in 00-shared/10.
1. Navigation & transitions
| Route | Transition |
|---|---|
| Home → Leave workspace | platform default push (per 00-shared/08) |
| My Requests → New Request | push (m-base slide) |
| List → Detail | bottom sheet (mobile) / right pane (tablet) |
| Approvals → Assign Substitution | bottom sheet with form |
| Balance ↔ Requests | horizontal tabs or back-stack; no bottom-nav duplication |
2. List screens (My Requests, Approvals, Substitutions)
| Gesture/event | Behavior |
|---|---|
| Pull-to-refresh | RefreshIndicator; refetch active filter; m-fast fade-in on updated rows |
| Tap row | open detail sheet (My Requests) / nothing extra (Approvals rows open action sheet) |
| Filter chip tap | refetch ?status= server-side (leave.controller.ts:40); keep scroll position |
| Swipe | none (no destructive actions exist) |
| Offline open | cached snapshot + AppOfflineBanner; CTA disabled for mutating actions |
| Loading | AppSkeleton rows (never spinners for whole list) |
| Empty | AppEmptyState with contextual CTA ("Request leave", "No substitutions") |
3. Request form
| Interaction | Spec |
|---|---|
| Type picker | dropdown; types cached in LeaveTypesCubit after first GET /leave/types |
| Date pickers | DatePicker; select start → auto-suggest end ≥ start (clamp); days preview updates live |
| Live days preview | recompute on each date change (inclusive count, leave.service.ts:310-312) with weekend hint |
| Submit | AppButton loading state; no optimistic creation (id is server-assigned); on 200 → toast "Submitted — pending approval", pop to list where new row appears at top (createdAt desc, leave.service.ts:168) |
| 404 type | inline + auto-refetch types |
| Double-tap submit | guarded (in-flight flag) |
4. Decision interaction (Approvals Queue)
| Interaction | Spec |
|---|---|
| Approve | 1 tap → PATCH with {action:"approve"} → row shows neutral "processing" → on 200 flips to approved & leaves pending list; on 409 reverts + toast |
| Reject | tap → sheet: optional note + confirm → PATCH {action:"reject", note} |
| Optimistic policy | no optimistic status change — balance is affected (leave.service.ts:183-190); wait for 200, then animate chip swap (m-fast) |
| 409 insufficient balance | row gets inline error chip "Insufficient balance"; approve disabled for that row until refetch; reject remains available |
| 409 already decided | toast + row removed on refetch |
| 409 self-decision | row never rendered for self (client filter) |
5. Assign substitution sheet
| Interaction | Spec |
|---|---|
| Teacher picker | search-as-you-type against teachers module (forward-looking); shows clash-free candidates only where known |
| Date picker | clamped hint to approved leave range (client only — gap) |
| Time pickers | start→end chained; end defaults start+1 h |
| Submit | on 409 clash → inline error under time fields; on 404 → toast; on 200 → toast + switch to Substitutions list |
6. Calendar
| Interaction | Spec |
|---|---|
| Month pager | swipe (mobile) + chevrons; fetch from/to per month (leave.controller.ts:85-91) |
| Day tap | bottom sheet listing approved requests that day |
| Today | outlined cell; selected month highlighted |
| Refresh | pull-to-refresh refetches visible month |
7. Feedback & haptics
- Success toasts:
AppSnackbar(short); decision approve = subtle haptic (HapticFeedback.selectionClick); errors =mediumImpact+ inline error (00-shared/08). - No confetti; no destructive-confirm for approve (only reject).
8. Offline matrix
| Screen | Read | Write |
|---|---|---|
| Balance | cached snapshot (≤ 1 session) | n/a |
| My Requests | cached list; status may be stale → banner | block new request (needs live type/balance truth) |
| Approvals | cached list | block decisions (balance check server-side, leave.service.ts:184-190) |
| Substitutions | cached list | block |
| Calendar | cached month | n/a |
9. Accessibility interactions
- Focus order: filters → list → FAB/CTA; sheets trap focus; dismiss via swipe-down + Esc (desktop) + scrim tap.
- All mutations require a visible confirmation state (no silent writes).
- Reduced motion:
m-fastbecomes instant (per 00-shared/09).
11 — Design System Mapping (Leave Module)
- 1. Color semantics
- 2. Typography (from 00-shared/02)
- 3. Spacing & shape
- 4. Iconography
- 5. Elevation & state layers
- 6. Dark mode & density
- 7. Motion mapping
- 8. Component → design-system traceability
Maps every leave surface to the shared Material 3 token set (00-shared/02 Design Tokens — proposed defaults until a designer finalizes them) and the shared component system (00-shared/03). No hardcoded colors; all module-specific semantic usage is enumerated here.
1. Color semantics
| Semantic | Token (00-shared/02) | Usage |
|---|---|---|
Leave status pending | color.tertiary (+ onTertiary) | StatusChip dot/fill; balance "0 remaining" not used here |
Leave status approved | color.success | StatusChip; calendar day tint; approval confirm button |
Leave status rejected | color.error | StatusChip; rejected row accent; over-entitlement bar |
Leave status cancelled | color.surfaceVariant + onSurfaceVariant | defensive render (unreachable, leave-request.schema.ts:11) |
Substitution assigned | color.primary | SubstitutionTile chip (only reachable state, leave.service.ts:261) |
| Calendar leave-day | color.primaryContainer fill, onPrimaryContainer text | CalendarDayCell |
| Calendar today | color.outline border | today ring |
| Insufficient balance | color.errorContainer + onErrorContainer | ApprovalTile inline chip |
| Info caption ("includes weekends") | color.onSurfaceVariant | request form caption |
2. Typography (from 00-shared/02)
| Element | Style |
|---|---|
| Screen title (AppBar) | text.titleLarge |
| Balance headline number | text.headlineSmall (tabular figures font-feature to avoid width jump) |
| Card title / request range | text.titleMedium |
| Card sub-line (reason, notes, metadata) | text.bodyMedium onSurfaceVariant |
| Chip label | text.labelMedium |
| Caption ("includes weekends", "live balance") | text.bodySmall onSurfaceVariant |
3. Spacing & shape
- Card padding
space.m(16); internal gapsspace.s(8) /space.xs(4). - Chip shape
shape.pill; cardsshape.lg(16) consistent with 00-shared/02. - List row height ≥ 64 dp (48 dp min target).
4. Iconography
| Icon | Use |
|---|---|
Icons.event_busy | request tile leading (or type-code badge text) |
Icons.balance / Icons.pie_chart | balance screen header |
Icons.check_circle_outline / Icons.cancel_outlined | approve / reject actions |
Icons.swap_horiz | substitutions |
Icons.calendar_month | calendar |
5. Elevation & state layers
- Cards:
elevation.level1resting,level2hover (desktop), pressed overlaystateLayer.pressedper M3 (00-shared/02). - Bottom sheets:
elevation.level3; scrimscrim@40%.
6. Dark mode & density
- All tokens are theme-aware (00-shared/02); status colors use light/dark variants automatically.
- Density: list rows
density.comfortableon mobile,density.compacton desktop tablets for the approvals queue (per 00-shared/02).
7. Motion mapping
| Motion token | Where |
|---|---|
m-fast (120 ms) | chip state swap after decision; pull-to-refresh fade |
m-base (200 ms) | screen push; sheet open/close; progress bar fill |
m-slow (300 ms) | calendar month cross-fade on pager |
8. Component → design-system traceability
| Leave component (07) | Shared primitives | Tokens |
|---|---|---|
| StatusChip | AppChips (variant) | color.success/error/tertiary, shape.pill, text.labelMedium |
| BalanceCard | AppCard + LinearProgress | color.primary, text.headlineSmall |
| RequestTile / ApprovalTile | AppListTile + AppBadge + AppMenu | spacing per 03, state layers |
| SubstitutionTile | AppListTile + AppChips | color.primary chip |
| ApprovalActionSheet | AppBottomSheet + SegmentedButton + AppTextField + AppButton | elevation.level3 |
| LeaveTypeCard | AppCard + AppBadge | text.titleMedium |
| CalendarDayCell / MonthGrid | (module-specific) | color.primaryContainer, outline, shape.md |
12 — API Mapping (Leave Module)
- 1. Endpoint table
- 2. Request bodies
- 3. Response shapes
- 4. Error contract (all 4xx/5xx in standard envelope, 00-shared/07)
- 5. Events emitted (side effects)
- 6. Query semantics
- 7. Gaps (no endpoint today)
Exact endpoint contract for the Leave module. Source:
leave.controller.ts(routes),leave.service.ts(semantics), DTOs (bodies), schemas (fields),docs/IMPLEMENTATION_PLAN.md((planned)). Envelope and error conventions: 00-shared/07. All endpoints are under@Controller('leave')withJwtAuthGuard+ApiBearerAuth(leave.controller.ts:25-28); URI prefix/api/v1permain.tsversioning.
1. Endpoint table
| # | Method | Path | Summary | Source | Access |
|---|---|---|---|---|---|
| 1 | POST | /api/v1/leave/requests | Create leave request | leave.controller.ts:32-36 | any authenticated user |
| 2 | GET | /api/v1/leave/requests | List requests (status, userId filters) | leave.controller.ts:38-47 | own only; all for org_admin (leave.service.ts:163-167) |
| 3 | PATCH | /api/v1/leave/requests/:id/approve | Approve / reject | leave.controller.ts:49-53 | any (server blocks self-decision :179-180); client: admin |
| 4 | GET | /api/v1/leave/balance/:userId | Live balance for user | leave.controller.ts:55-59 | any (no server restriction — gap) |
| 5 | POST | /api/v1/leave/types | Create leave type | leave.controller.ts:61-65 | org admin (client) |
| 6 | GET | /api/v1/leave/types | List leave types | leave.controller.ts:67-71 | any |
| 7 | POST | /api/v1/leave/substitutions | Assign substitute | leave.controller.ts:73-77 | org admin (client) |
| 8 | GET | /api/v1/leave/substitutions/teacher/:id | Substitutions for teacher | leave.controller.ts:79-83 | substitute teacher |
| 9 | GET | /api/v1/leave/calendar | Approved leave in range | leave.controller.ts:85-91 | any |
(planned) — IMPLEMENTATION_PLAN.md:153-161 lists the same nine endpoints;
no additional leave endpoints are planned in that doc.
2. Request bodies
| Endpoint | DTO | Fields |
|---|---|---|
| 1 | CreateLeaveRequestDto (create-leave-request.dto.ts:4-21) | leaveTypeId (mongoId, req), startDate (ISO date, req), endDate (ISO date, req), reason? |
| 3 | LeaveDecisionDto (leave-decision.dto.ts:9-17) | action (approve|reject, enum :4-7, req), note? |
| 5 | CreateLeaveTypeDto (create-leave-type.dto.ts:4-28) | code (str, req), name (str, req), daysPerYear (int ≥ 1, req), carryForward? (bool, default false), maxCarryForward? (int ≥ 0) |
| 7 | AssignSubstitutionDto (assign-substitution.dto.ts:4-37) | leaveRequestId, substituteTeacherId, classId, subjectId (mongoIds, req), date (ISO, req), startTime, endTime (str HH:mm, req), notes? |
3. Response shapes
- 2/3 → created/updated
LeaveRequestdocument:_id, tenantId, userId, leaveTypeId, startDate, endDate, daysRequested, reason?, status, decidedBy?, decidedAt?, decisionNote?, createdAt, updatedAt, version(schemaleave-request.schema.ts:16-48+BaseSchemaaudit fields). - 2 (list) →
LeaveRequest[]sortedcreatedAtdesc (leave.service.ts:168). - 4 →
LeaveBalanceEntry[]:{leaveTypeId, code, name, daysPerYear, carriedForward, daysUsed, daysRemaining}(leave.service.ts:66-74). - 6 →
LeaveType[]sortedcodeasc (leave.service.ts:216). - 8 →
Substitution[]sorteddateasc (leave.service.ts:275-280). - 9 →
LeaveRequest[](approved only) sortedstartDateasc (leave.service.ts:288-295); default range = current month (:283-287).
4. Error contract (all 4xx/5xx in standard envelope, 00-shared/07)
| HTTP | Trigger | Message | Source |
|---|---|---|---|
| 400 | endDate < startDate | endDate must be on or after startDate. | leave.service.ts:133-134 |
| 401 | missing/invalid JWT | — | leave.controller.ts:27 |
| 404 | unknown leave type | Leave type not found. | leave.service.ts:129 |
| 404 | unknown request | Leave request not found. | :174 |
| 404 | requester has no teacher record | No teacher record found for the leave requester. | :236-237 |
| 409 | request not pending | Leave request is already <status>. | :175-178 |
| 409 | self-decision | You cannot decide your own leave request. | :179-180 |
| 409 | approve when balance insufficient | Insufficient leave balance. | :188-189 |
| 409 | substitution on non-approved request | Leave request must be approved before assigning a substitution. | :227-230 |
| 409 | substitute slot clash | Substitute teacher already assigned in this time slot. | :247-248 |
| 11000* | duplicate leave-type code (Mongo unique (tenantId, code)) | not mapped server-side — gap | leave-type.schema.ts:29 |
*Surfaces as a generic 500-class error today; client maps duplicate code from
the raw key error until server adds a mapper.
5. Events emitted (side effects)
| Event | Payload (events/leave-events.ts) | Emitted at |
|---|---|---|
LeaveRequested | leaveRequestId, userId, leaveTypeCode, startDate, endDate, daysRequested (:1-8) | leave.service.ts:154 |
LeaveApproved / LeaveRejected | leaveRequestId, userId, leaveTypeCode, startDate, endDate, action, note? (:10-18) | :210 |
SubstitutionAssigned | substitutionId, absentTeacherId, substituteTeacherId, classId, date (:20-26) | :271 |
Consumers: Notifications (planned, IMPLEMENTATION_PLAN.md:163); audit.
6. Query semantics
- List (2):
status∈ enum (pending\|approved\|rejected\|cancelled,leave-request.schema.ts:7-12);userIdonly effective for admins (leave.service.ts:167). Non-admin always receives own rows only. - Calendar (9):
from/toISO dates; overlap querystartDate ≤ to AND endDate ≥ from(:291-292).
7. Gaps (no endpoint today)
Cancel request (cancelled status unreachable) · edit/withdraw pending
request · request detail GET /:id · leave-type update/delete · substitution
status transitions (completed/cancelled) · balance for "self" convenience
(requires own userId) · leave.* RBAC permissions
(permissions.constants.ts:1-97 has none).
13 — State Management (Leave Module)
- 1. Cubit map
- 2. State shapes
- 3. Data lifecycle rules
- 4. Status state machine (authoritative)
- 5. Cross-cubit coordination
- 6. Error handling
Cubit architecture for the leave feature, its state shapes, data lifecycle, and the authoritative status state machine. Client architecture (Bloc/Cubit) is a recommendation (00-shared/01 §9; shared patterns 00-shared/06).
1. Cubit map
| Cubit | Owns | Backing endpoint(s) |
|---|---|---|
LeaveBalanceCubit | balance entries for current user | GET /leave/balance/:userId (leave.controller.ts:55-59) |
LeaveTypesCubit | leave type catalog (incl. defaults) | GET /leave/types (:67-71) |
LeaveRequestsCubit | current user's requests + filter | GET /leave/requests (:38-47) |
LeaveRequestFormCubit | draft request + validation + submit | POST /leave/requests (:32-36) |
ApprovalsCubit | admin queue + filters + decision | GET /leave/requests?status= + PATCH /leave/requests/:id/approve (:38-53) |
SubstitutionsCubit | substitute's assignments | GET /leave/substitutions/teacher/:id (:79-83) |
SubstitutionFormCubit | assign-substitution draft + submit | POST /leave/substitutions (:73-77) |
LeaveCalendarCubit | approved month payload | GET /leave/calendar?from&to (:85-91) |
LeaveTypesAdminCubit | create type | POST /leave/types (:61-65) |
Shared session/user id comes from SessionCubit (00-shared/06).
2. State shapes
sealed class LeaveRequestsState:
idle | loading | loaded(List<LeaveRequest> rows, LeaveStatusFilter filter)
| error(ApiFailure failure, cachedRows)
sealed class ApprovalsState:
idle | loading | loaded(List<LeaveRequest> rows, filter) | deciding(requestId)
| error(failure)
sealed class BalanceState: idle | loading | loaded(List<LeaveBalanceEntry>) | error
sealed class FormState<T>: draft(T) | submitting | submitted(T result) | failure(...)
Models mirror the wire contract exactly (12_API_Mapping §3) with
fromJson; no derived fields stored — days preview is computed in the form
cubit (inclusive count, leave.service.ts:310-312).
3. Data lifecycle rules
- Balance is never long-cached: live-computed server-side
(
leave.service.ts:87-88); refetch on screen open + pull-to-refresh; discard on logout; year rollover needs no client logic (server computes per calendar year,:92-97). - Types: fetch once per session (defaults seeded server-side,
:299-306); refresh after type creation. - Requests/Approvals: refetch on filter change and after decisions
(decision 409 "already decided" → refetch,
:175-178). - Calendar: refetch per visible month; keep last month for offline.
- All writes are non-optimistic for decisions (balance impact,
:183-190) and creation (server-assigned_id); only UI-neutral loading state changes locally.
4. Status state machine (authoritative)
stateDiagram-v2
[*] --> pending : POST /leave/requests (createRequest)
pending --> approved : PATCH /leave/requests/:id/approve\n{action: approve} (balance check, not self)
pending --> rejected : PATCH .../approve {action: reject}
pending --> pending : approve attempt fails\n409 (already decided / insufficient balance / self)
approved --> [*] : substitutions assignable\n(POST /leave/substitutions, approved only)
rejected --> [*]
cancelled : declared in schema (leave-request.schema.ts:11)\nunreachable — no endpoint sets it (gap)
Notes:
- Source of transitions:
leave.service.ts:171-212(decidesetsapproved/rejectedonly frompending;createRequestsetspending,:144). cancelledexists in the enum (leave-request.schema.ts:11) but no code path produces it — client renders it defensively only.- Substitution mini-machine (
substitution.schema.ts:7-11):assigned(set at creation,leave.service.ts:261) →completed/cancelled— no service transitions exist; states unreachable today.
5. Cross-cubit coordination
LeaveRequestsCubitandApprovalsCubitboth listen toLeaveRequested/LeaveApproved/LeaveRejectedevents (via socket or refetch-on-navigation(forward-looking)— no push today).- After
ApprovalsCubit.decidesuccess → invalidateBalanceStatefor that user (deduction,:184-190). - After
LeaveRequestFormCubit.submitsuccess → prepend row inLeaveRequestsCubit(list iscreatedAtdesc,:168).
6. Error handling
Map HTTP → domain failure: 400 validation, 404 not found (refetch types),
409 conflict (surface message verbatim — they are user-actionable:
leave.service.ts:176-189,227-230,247-248), 401 session refresh, 11000
duplicate code. Single ApiFailure type from 00-shared/06.
14 — QA Checklist (Leave Module)
- 1. Request creation (P0)
- 2. Overlap validation (P1 — known gap)
- 3. Day-count math (P1)
- 4. Balance (P1)
- 5. Approval (P0)
- 6. List & filters (P1)
- 7. Substitutions (P1)
- 8. Calendar (P2)
- 9. Cross-cutting
- 10. Known gaps to track (regression-lock as current behavior)
Test checklist anchored to the exact server semantics. Base QA baseline: 00-shared/10. Priority: P0 = blocks release, P1 = high, P2 = low. Known gaps are listed as explicit "known behavior" rows, not failures.
1. Request creation (P0)
| # | Case | Expected (source) |
|---|---|---|
| 1.1 | Happy path | 200, status pending, daysRequested = inclusive day count, userId = token user (leave.service.ts:136-144) |
| 1.2 | endDate < startDate | 400 endDate must be on or after startDate. (:133-134) |
| 1.3 | Unknown leaveTypeId | 404 Leave type not found. (:129) |
| 1.4 | Same-day request (start = end) | 200, daysRequested = 1 (inclusive formula :310-312) |
| 1.5 | leaveTypeId spoof in body | ignored — userId always from token (:138); verify no cross-user creation |
| 1.6 | Missing/blank reason | 200 (optional, create-leave-request.dto.ts:17-20) |
| 1.7 | No auth | 401 (leave.controller.ts:27) |
| 1.8 | daysRequested sent in body | rejected by DTO whitelist (not a DTO field, create-leave-request.dto.ts:4-21) |
2. Overlap validation (P1 — known gap)
| # | Case | Expected (source) |
|---|---|---|
| 2.1 | Same user, overlapping ranges, both pending | Both accepted — no overlap check in createRequest (leave.service.ts:126-156). Documented gap; client warns (06 §2.4). Test to lock current behavior + track gap |
| 2.2 | Same user, overlapping ranges, one approved | Same — allowed; balance math handles both (:99-103) |
| 2.3 | Cross-user overlap | Allowed by design (calendar is advisory) |
3. Day-count math (P1)
| # | Case | Expected (source) |
|---|---|---|
| 3.1 | Mon→Fri | daysRequested = 5 (calendar days) |
| 3.2 | Fri→Mon (weekend inside) | = 4 — weekends count (:310-312, ponytail note: working-day count only if policy changes) |
| 3.3 | Holiday inside range | counts as a day (no holiday calendar) |
| 3.4 | Dec 31 → Jan 2 (year span) | 3 days; balance attribution per startDate year (:101-103) |
| 3.5 | DST boundary | Math.floor((end-start)/86400000)+1 (:311) — verify with DST-shifted timezones (tz stored on user, user.schema.ts:52-53) |
4. Balance (P1)
| # | Case | Expected (source) |
|---|---|---|
| 4.1 | Formula | daysRemaining = daysPerYear + carriedForward − daysUsed (leave.service.ts:121) |
| 4.2 | Carry cap | min(maxCarryForward, max(0, daysPerYear − usedPrevYear)) (:107-112) |
| 4.3 | No carry (CL, ML, PL) | carriedForward = 0 always |
| 4.4 | SL/EL carry cap | SL max 30, EL max 60 (:37-51) |
| 4.5 | Balance reflects approval only | pending/rejected excluded (query filters APPROVED, :94-98) |
| 4.6 | Deduction after approval | refetch balance post-approve shows reduced daysRemaining |
| 4.7 | Defaults seeding | first call seeds 5 defaults idempotently (:299-306); double-fire concurrent calls → single set ($setOnInsert upsert, leave-type.repository.ts:17-31) |
5. Approval (P0)
| # | Case | Expected (source) |
|---|---|---|
| 5.1 | Approve pending | 200 → status=approved, decidedBy, decidedAt, decisionNote set (:195-200) |
| 5.2 | Reject pending | 200 → status=rejected; note optional (leave-decision.dto.ts:14-17) |
| 5.3 | Decide already-decided | 409 Leave request is already <status>. (:175-178) |
| 5.4 | Decide own request | 409 You cannot decide your own leave request. (:179-180) |
| 5.5 | Approve over balance | 409 Insufficient leave balance. (:188-189); reject still allowed |
| 5.6 | Approval race — two admins, same request, near-simultaneous PATCH | Both read pending; second write wins; at least one caller gets 409 (no atomic conditional update — verify behavior: second updateById overwrites; test + raise as gap (planned) optimistic-lock awareness, base.schema.ts version) |
| 5.7 | Balance check race — two approvals crossing a shared balance | Both pass check, both approve → daysUsed exceeds entitlement. Live-computed, no lock — document as known limitation; mitigation (forward-looking) |
| 5.8 | Non-admin decision | Server permits (only JWT guard, leave.controller.ts:27) — client gates admin; server gap noted in 12 §7 |
6. List & filters (P1)
| # | Case | Expected (source) |
|---|---|---|
| 6.1 | Non-admin list | Only own rows (leave.service.ts:166-167) |
| 6.2 | Admin list all | All rows; userId filter effective (:163-167) |
| 6.3 | status filter | enum-validated (leave.controller.ts:40); invalid → 400 |
| 6.4 | Sort | createdAt desc (:168) |
| 6.5 | userId filter as non-admin | ignored (overridden to self, :167) |
7. Substitutions (P1)
| # | Case | Expected (source) |
|---|---|---|
| 7.1 | Assign on approved request | 200, status=assigned (:252-263) |
| 7.2 | Assign on pending/rejected | 409 (:227-230) |
| 7.3 | Requester without Teacher record | 404 (:232-238) |
| 7.4 | Exact-slot duplicate | 409 clash (:241-250) |
| 7.5 | Partial overlap (08:00-10:00 vs 09:00-11:00) | 409 (timeOverlaps, :314-321) |
| 7.6 | Adjacent slots (08:00-09:00 vs 09:00-10:00) | allowed (strict </>, :320) |
| 7.7 | Substitute list | sorted date asc (:275-280) |
| 7.8 | completed/cancelled | Unreachable — no endpoint (known gap; schema substitution.schema.ts:7-11) |
8. Calendar (P2)
| # | Case | Expected (source) |
|---|---|---|
| 8.1 | Range filter | startDate ≤ to && endDate ≥ from (:291-292) |
| 8.2 | Approved only | pending/rejected excluded (:290) |
| 8.3 | No from/to | current month default (:283-287) |
| 8.4 | Tenant isolation | auto-scoped via BaseRepository (tenantId) |
9. Cross-cutting
| # | Case | Expected |
|---|---|---|
| 9.1 | Tenant isolation | User A cannot see/decide tenant B rows (base-scoped queries) |
| 9.2 | Soft-deleted rows | excluded by BaseRepository |
| 9.3 | Offline app | reads cached; writes blocked (10 §8) |
| 9.4 | a11y sweep | 00-shared/09 (chips not color-only, live regions for errors) |
| 9.5 | Analytics (proposed) | leave.* events fire once per action |
10. Known gaps to track (regression-lock as current behavior)
- No same-user overlap rejection (2.1).
- No cancel/edit endpoint (
cancelledunreachable,leave-request.schema.ts:11). - No atomic approve (5.6) / balance-crossing race (5.7).
- No
leave.*permissions (permissions.constants.ts:1-97); no RBAC guard on decide/substitution endpoints (leave.controller.ts:27). - Duplicate
codeE11000 not mapped to 409 (12 §4). - Substitution status transitions + absence-side list missing.
15 — Flutter Implementation Guide (Leave Module)
- 1. Folder layout
- 2. Models (wire-literal)
- 3. Repository
- 4. Cubits (patterns per 13)
- 5. Screens
- 6. Offline & caching
- 7. Tests
- 8. Rollout order
Concrete build guide for the Flutter client against the leave API surface. App architecture, DI, and conventions per 00-shared/11 (Bloc/Cubit recommendation). All wire contracts here are literal to the NestJS module.
1. Folder layout
lib/features/leave/
├── models/
│ ├── leave_request.dart # LeaveRequest (schema leave-request.schema.ts:16-48)
│ ├── leave_balance_entry.dart # LeaveBalanceEntry (leave.service.ts:66-74)
│ ├── leave_type.dart # LeaveType (leave-type.schema.ts:9-25)
│ └── substitution.dart # Substitution (substitution.schema.ts:14-47)
├── repositories/
│ ├── leave_repository.dart # all 9 endpoints (12 §1)
│ └── leave_repository_impl.dart
├── cubits/
│ ├── leave_balance_cubit.dart
│ ├── leave_types_cubit.dart
│ ├── leave_requests_cubit.dart
│ ├── leave_request_form_cubit.dart
│ ├── approvals_cubit.dart
│ ├── substitutions_cubit.dart
│ ├── substitution_form_cubit.dart
│ ├── leave_calendar_cubit.dart
│ └── leave_types_admin_cubit.dart
├── widgets/
│ ├── status_chip.dart # 07 §1
│ ├── balance_card.dart # 07 §2
│ ├── request_tile.dart # 07 §3
│ ├── approval_tile.dart
│ ├── substitution_tile.dart # 07 §4
│ ├── approval_action_sheet.dart # 07 §5
│ ├── leave_type_card.dart # 07 §6
│ └── calendar_month_grid.dart # 07 §7
├── screens/
│ ├── leave_balance_screen.dart
│ ├── leave_request_form_screen.dart
│ ├── my_requests_screen.dart
│ ├── approvals_queue_screen.dart
│ ├── substitutions_screen.dart
│ ├── leave_types_screen.dart
│ └── leave_calendar_screen.dart
└── leave_di.dart # get_it registrations (00-shared/11)
2. Models (wire-literal)
enum LeaveRequestStatus { pending, approved, rejected, cancelled } // leave-request.schema.ts:7-12
enum SubstitutionStatus { assigned, completed, cancelled } // substitution.schema.ts:7-11
class LeaveRequest {
final String id, userId, leaveTypeId;
final DateTime startDate, endDate;
final int daysRequested;
final String? reason;
final LeaveRequestStatus status;
final String? decidedBy; final DateTime? decidedAt; final String? decisionNote;
// fromJson: parse dates as UTC (server sends ISO; render in user tz,
// user.schema.ts:52-53)
}
- Never serialize
userId/daysRequestedin create payload — server owns them (leave.service.ts:136-138). LeaveBalanceEntryfields:leaveTypeId, code, name, daysPerYear, carriedForward, daysUsed, daysRemaining— use tabular figures for the headline (11 §2).
3. Repository
class LeaveRepository {
Future<LeaveRequest> createRequest(CreateLeaveRequestDto dto); // POST /leave/requests
Future<List<LeaveRequest>> listRequests({LeaveRequestStatus? status, String? userId});
Future<LeaveRequest> decide(String id, {required String action, String? note}); // PATCH .../approve
Future<List<LeaveBalanceEntry>> getBalance(String userId); // GET /leave/balance/:userId
Future<List<LeaveType>> listTypes(); // GET /leave/types
Future<LeaveType> createType(CreateLeaveTypeDto dto); // POST /leave/types
Future<Substitution> assignSubstitution(AssignSubstitutionDto dto); // POST /leave/substitutions
Future<List<Substitution>> listSubstitutionsForTeacher(String teacherId);
Future<List<LeaveRequest>> calendar({String? from, String? to}); // GET /leave/calendar
}
Error mapping: parse envelope (00-shared/07); throw typed
LeaveApiException(statusCode, message); surface 400/404/409 messages
verbatim (they are user-actionable, 12 §4); map Mongo 11000 to
"Code already exists".
4. Cubits (patterns per 13)
class LeaveRequestFormCubit extends Cubit<FormState> {
// draft: leaveTypeId, startDate, endDate, reason
int get previewDays => dateDifferenceInclusive(start, end); // mirror leave.service.ts:310-312
Future<void> submit() async {
emit(submitting);
try { final r = await repo.createRequest(draft); emit(submitted(r)); }
on LeaveApiException catch (e) { emit(failure(e)); } // 404 → reload types
}
}
LeaveBalanceCubit.load()on screen open — never reuse session cache for balance (live-computed,leave.service.ts:87-88).ApprovalsCubit.decide(id, action, note)→ on 409 "already decided" refetch queue; on 409 "insufficient balance" emit per-row error (10 §4).LeaveCalendarCubit.loadMonth(month)→from = yyyy-MM-01T00:00:00.000Z,to= last day of month (server default semantics,:283-287).
5. Screens
| Screen | Cubit | Notes |
|---|---|---|
| Balance | LeaveBalanceCubit + LeaveTypesCubit | skeleton per default type count (5, leave.service.ts:30-64); caption "weekends count" |
| Request form | LeaveRequestFormCubit | dropdown types; date pickers chained (end ≥ start); live days preview; block offline (10 §8) |
| My Requests | LeaveRequestsCubit | filter chips → server ?status=; pull-to-refresh; detail bottom sheet |
| Approvals | ApprovalsCubit | default pending; hide own rows (self-decision 409, :179-180); decision sheet = ApprovalActionSheet |
| Substitutions | SubstitutionsCubit | needs own teacherId from teachers module lookup |
| Types admin | LeaveTypesAdminCubit | create sheet; E11000 inline |
| Calendar | LeaveCalendarCubit | month grid; day tap → approved-request sheet |
6. Offline & caching
Hive/driftcache: requests list, substitutions, last calendar month, types (TTL: session). Balance: snapshot only, flagged stale.- Writes blocked offline (mutations need server truth: balance check on
decide
:184-190, clash check:241-250).
7. Tests
- Unit: model
fromJson; day-count function (incl. weekend + DST cases, 14 §3); cubit error mapping (404/409/11000). - Widget:
StatusChiprenders all 4 statuses;BalanceCardprogress clamp; form preview days. - Integration: repository against mock HTTP with envelope fixtures
(
00-shared/07); contract fixtures generated from 12 §3 shapes. - Golden: chips, cards, empty states (tokens per
00-shared/02).
8. Rollout order
- Balance + My Requests (read surfaces, high frequency).
- Request form (write path with server validation).
- Approvals queue + decision (admin path, 409 handling).
- Substitutions (needs teachers lookup).
- Calendar; notifications
(planned)last (event-driven,IMPLEMENTATION_PLAN.md:163).
01 — Product Overview (Library Module)
- 1. Purpose
- 2. Business goals
- 3. User goals
- 4. Stakeholders
- 5. Why this exists
- 6. Dependencies
- 7. Success metrics
- 8. Edge cases (contract level)
- 9. Assumptions (module)
- 10. Open questions (module-grain; global ledger in 00-shared/12)
- 11. Glossary (this module)
StudyLyon — multi-tenant ERP / School Management API. This package designs the Library module client (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, domain events, permissions, and wire contracts are derived directly from
src/modules/library/**,src/modules/rbac/permissions.constants.ts, anddocs/IMPLEMENTATION_PLAN.md. No feature is invented — anything not present in source is flagged(planned)/(proposed)/(forward-looking)in Assumptions & Open Questions.
Heads-up: per the PRD, the mobile client is out of Phase 1; this package is the forward-looking spec the client will be built against later. Everything below is best-effort UI design on top of the current backend surface.
Blueprint note: studylyon-blueprint/04-Modules/ has no dedicated Library doc;
the module is only sketched as "Books / Issue / Return / Fines" responsibilities
(MODULE_ARCHITECTURE.md:546-557) and appears in the fee-category list
(COLLECTIONS.md:2247). The code is the source of truth.
1. Purpose
Library manages the school's physical book collection and the borrow lifecycle:
- Catalog: books with title/author/ISBN, optional publisher/category/edition,
copy counts (
totalCopies/availableCopies), and a status (available|borrowed|damaged|lost). - Issue/return: staff hand a copy to a student with an explicit due date; returning closes the record, restores availability, and computes a fine when late.
- Fines: computed server-side at return time at a fixed daily rate, tracked as
fineStatus (pending|paid|waived), payable via a dedicated endpoint. - Borrow visibility: full history and active-borrow lists per student.
| Responsibility | Source |
|---|---|
| Book CRUD (create/list/search/get/patch/soft-delete) | library.controller.ts:27-59, library.service.ts:34-105 |
| Unique ISBN per tenant (409 on duplicate) | library.service.ts:35-40, book.schema.ts:51 |
| Issue book (availability + 5-book cap, decrement copies) | library.service.ts:107-150 |
Return book (restore copies, compute fine, BookReturned event) | library.service.ts:152-193 |
| Borrow history / active borrows per student | library.service.ts:195-201, borrow-record.repository.ts:21-43 |
| Pay fine | library.service.ts:203-209 |
| Daily fine rate = 5 units/day (hardcoded) | library.service.ts:25,211-217 |
Permissions vocabulary (books.read/create/update/delete/issue/return, fines.pay) | permissions.constants.ts:55-61 |
| Tenant scoping + soft-delete on every query | BaseRepository (base.repository.ts), all repos extend it |
2. Business goals
| Goal | Measure |
|---|---|
| No duplicate books | unique index {tenantId, isbn} (book.schema.ts:51) + service 409 (library.service.ts:37-39) |
| Never over-issue | availableCopies >= 1 checked before decrement (library.service.ts:109-111) |
| Never exceed student limit | max 5 active borrows enforced (library.service.ts:116-120) |
| Fines always computable | fineAmount = overdueDays × 5 at return (library.service.ts:211-217) |
| No orphaned deletes | book with active borrows cannot be deleted — 409 (library.service.ts:97-102) |
| Cross-tenant isolation | every query tenant-scoped via BaseRepository.scopedFilter |
| Audit trail | BookIssued / BookReturned domain events on the event bus (library.service.ts:141-148,180-191) |
3. User goals
- Librarian / staff: search the catalog; add, edit, delete books; issue and return copies; see overdue borrowers; collect fine payments.
- Student: browse/search the catalog; see which books are currently on loan to them and their history; know what's overdue.
- Parent: (read-only, via student) see the child's active loans and any fine.
- Org admin: catalog oversight, permission assignment (who may issue/return),
configuration of limits
(planned).
4. Stakeholders
Librarian/staff with books.* permissions, org admin (RBAC), students, parents,
the RBAC module (permissions.constants.ts), events/notifications pipeline, QA +
design + engineering.
5. Why this exists
A school library without tracking loses books and revenue. The backend enforces the hard invariants (ISBN uniqueness, availability, 5-book cap, tenant isolation, soft delete). The client's job is to present the catalog and loan state authoritatively, never guess server state, and treat every write (create, issue, return, fine pay) as server-confirmed.
6. Dependencies
| Dependency | Role | Source |
|---|---|---|
| Students module | studentId on borrow records (ref: 'Student') | borrow-record.schema.ts:25-26 |
| Users module | issuedBy actor (ref: 'User', currently never written) | borrow-record.schema.ts:28-29 |
| Event bus | BookIssued, BookReturned events | library.service.ts:141-148,180-191 |
| Files module | cover/attachment upload surface (not wired to books yet) | files.controller.ts:29-71 |
| RBAC | books.*, fines.pay permissions (enforced later (planned)) | permissions.constants.ts:55-61 |
7. Success metrics
- Catalog search (title/author/ISBN) round-trip < 2 s (regex search, indexed title/author).
- Duplicate-ISBN attempt handled as 409 100% of the time.
- Issue of an exhausted book always blocked (409), even under concurrent requests (see QA-1 in 14_QA_Checklist.md — read-then-write race today).
- Fine amount at return always equals
ceil(daysOverdue) × 5unless staff overrides. - Zero cross-tenant leaks (
BaseRepositoryscope).
8. Edge cases (contract level)
- Duplicate ISBN → 409
DUPLICATE_RESOURCE"Book with ISBN "..." already exists." (library.service.ts:37-39). - No copies available → 409 "No copies available for borrowing."
(
library.service.ts:109-111). - Student at 5 active loans → 409 "Student already has maximum number of borrowed
books." (
library.service.ts:116-120). - Delete book with active loans → 409 "Cannot delete book with active borrow
records." (
library.service.ts:99-101); delete is a soft delete (book.repository.tsviaBaseRepository.softDelete,library.service.ts:103). - Return a non-active record → 409 "Book was not actively borrowed."
(
library.service.ts:155-157). - Unknown book/record → 404
RESOURCE_NOT_FOUND(library.service.ts:80,92,103,154,170,207). - Invalid Mongo id →
CastError→ 400VALIDATION_ERROR"Invalid resource identifier." (http-exception.filter.ts). - Copy-count edits adjust
availableCopiesby the same delta, floored at 0 (library.service.ts:87-90). - Staff override fine — return DTO may set
fineAmountexplicitly (return-book.dto.ts:9-13); server usesdto.fineAmount ?? calculateFine(dueDate)(library.service.ts:159). fineStatuson time — records withfineAmount = 0keepfineStatus: undefined(library.service.ts:166);WAIVEDis never produced by any endpoint.
9. Assumptions (module)
- PRD: mobile client out of Phase 1 — forward-looking spec; backend remains the contracts authority.
- Endpoints are guarded only by
JwtAuthGuard(library.controller.ts:22) — no@Permissions()metadata anywhere;books.read/create/update/delete/issue/returnandfines.payexist inALL_PERMISSIONS(permissions.constants.ts:55-61) but are not enforced. Real RBAC is(planned)(OQ-1). - Overdue is not computed by the server.
BorrowStatus.OVERDUEand repositoryfindOverdue()exist (borrow-record.schema.ts:9-11,borrow-record.repository.ts:34-43) but no service endpoint/worker sets it. Overdue detection is client-derived today; a scheduled scan is(planned)(IMPLEMENTATION_PLAN.md:228) (OQ-2). issuedByis never populated — the schema has the field (borrow-record.schema.ts:28-29) butissueBookdoesn't set it (library.service.ts:132-139) (OQ-3).- Fines are units (plain
Number,library.service.ts:25rate = 5); no currency/format contract server-side. BookStatus.DAMAGED/LOSTandBorrowStatus.LOSTexist in enums but no endpoint transitions to them — damage/loss workflow(planned)(OQ-4).- No pagination on borrow endpoints (
library.service.ts:195-201) — client paging(proposed). - Search covers title/author/ISBN only (
book.repository.ts:26-30); category/ publisher/status filters must be client-side (OQ-5). - Catalog import/export is
(planned)(IMPLEMENTATION_PLAN.md:172,228); QR/barcode scanning is(forward-looking); analytics(proposed).
10. Open questions (module-grain; global ledger in 00-shared/12)
| # | Item | Impact |
|---|---|---|
| OQ-1 | No RBAC decorators on library endpoints; perms exist but unenforced | Role-gated UI waits for guard wiring |
| OQ-2 | No overdue worker — OVERDUE status and findOverdue() unused | Overdue badge derived client-side; server scan (planned) |
| OQ-3 | issuedBy never written on issue | "Issued by" attribution unavailable |
| OQ-4 | No damage/lost transitions, no renew/reserve endpoints | Those workflows (planned) |
| OQ-5 | Server search limited to title/author/ISBN; no status/category filters | Client-side filter only |
| OQ-6 | payFine doesn't guard status — can pay a fine on a still-active loan, or twice (idempotent overwrite) | Pay button gating is client responsibility |
| OQ-7 | Issue is read-then-write (availableCopies) — no atomic conditional update | Concurrent double-issue race (QA-1) |
| OQ-8 | No book-cover field on Book; files module exists separately | Cover upload (proposed) |
| OQ-9 | No me borrows endpoint — student view needs studentId from profile | GET /books/borrows/:studentId/active requires profile lookup |
11. Glossary (this module)
| Term | Meaning |
|---|---|
| Book | books doc: title, author, isbn, publisher?, category?, edition?, totalCopies, availableCopies, status `available |
| Borrow record | borrow_records doc: bookId, studentId, issuedBy?, borrowedAt, dueDate, returnedAt?, status `active |
| Issue | staff lends a copy: decrement availability, create ACTIVE record with due date |
| Return | close an ACTIVE record: restore availability, set RETURNED + returnedAt, compute fine |
| Fine | overdueDays × 5 units (library.service.ts:25,211-217), status `pending |
| Envelope | {success, message, data, meta?, timestamp, requestId} (response-envelope.interceptor.ts:44-52) |
02 — User Personas (Library Module)
- 1. The Librarian (primary catalog + circulation user)
- 2. Catalog Admin / Org Admin (setup + oversight)
- 3. Student (borrower)
- 4. Parent (read-only observer)
- 5. Accounts / Bursar (fine collector, occasional)
- Permission → capability matrix
Who uses the library surface, what permissions they hold (
permissions.constants.ts:55-61), and what the client must show each. RBAC enforcement is(planned)(OQ-1) — today any authenticated user can call any library endpoint; these personas are the target post-RBAC model.
1. The Librarian (primary catalog + circulation user)
| Role | Staff member assigned library duty; holds books.read/create/update/delete, books.issue, books.return, fines.pay |
| Goals | Keep catalog accurate; move books in and out fast at the desk; collect fine payments; see what's overdue |
| Pains | Double-issuing a copy that has no stock; losing track of who owes what; duplicate ISBN entries |
| Key screens | Catalog list/search, book detail, issue form, return form, overdue view, borrowed-by-me (for lookups) |
| Behaviour notes | Fast scanning + typing; wants availability visible in every list row; wants a confirm step on money actions (fine pay) |
| Frequency | Daily, many transactions; optimizes for speed, tolerates confirmation dialogs |
2. Catalog Admin / Org Admin (setup + oversight)
| Role | Org admin or head librarian; books.read/update/delete (+ RBAC assignment in the future) |
| Goals | Onboard the collection (bulk import (planned)), fix metadata, retire books, define who may issue |
| Pains | Manual entry of hundreds of titles; deleting a book that is still loaned out (409 blocks it) |
| Key screens | Catalog list, book detail, book form (create/edit) |
| Behaviour notes | Desktop/tablet heavy; wants ISBN-uniqueness surfaced immediately (409) |
| Notes | Bulk catalog import (planned) — IMPLEMENTATION_PLAN.md:172,228 |
3. Student (borrower)
| Role | Student of the school; holds books.read in the future ((planned) RBAC) |
| Goals | Find a book (title/author/ISBN search), know if a copy is available, see what they currently hold and what's due/overdue, see fines |
| Pains | Not knowing whether a book is in stock; losing track of due dates; surprise fines |
| Key screens | Catalog list/search, book detail, borrowed-by-me (active + history), fine payment status |
| Behaviour notes | Reads availability badges before walking to the shelf; needs due-date prominence |
| Note | Student view needs their studentId — no me endpoint today (OQ-9); client must pull it from the Students profile state |
4. Parent (read-only observer)
| Role | Parent of a student |
| Goals | See child's current loans and any pending fine |
| Key screens | Borrowed-by-me (child's) view, read-only |
| Behaviour notes | No write actions; fine amounts shown with status `pending |
| Note | Depends on parent↔student linkage from the Students/Parents modules (planned) in this package |
5. Accounts / Bursar (fine collector, occasional)
| Role | Staff with fines.pay (and student.read) |
| Goals | Take fine payments at return time or later; mark paid |
| Key screens | Return form (fine computed inline), fine pay action from a borrow record |
| Behaviour notes | Wants the server's computed fine shown verbatim; records payment against fineStatus: pending records |
Permission → capability matrix
Permission (permissions.constants.ts:55-61) | Capability |
|---|---|
books.read | Catalog list/search/detail, borrow history |
books.create | Create book (add to catalog) |
books.update | Edit book metadata + copy counts |
books.delete | Soft-delete book (blocked while active loans exist) |
books.issue | Issue a copy to a student |
books.return | Return a copy, compute/override fine |
fines.pay | Mark a borrow record's fine paid |
Enforcement is
(planned): no@Permissionsdecorators onlibrary.controller.tsendpoints today (OQ-1). Until then the client can pre-emptively hide actions the user's role lacks, using this matrix.
03 — User Journey (Library Module)
- 1. Add a book to the catalog (librarian/catalog admin)
- 2. Find a book (student / librarian)
- 3. Issue a book to a student (librarian)
- 4. Return a book, settle the fine (librarian + bursar)
- 5. Student checks their loans (student / parent)
- 6. Librarian's overdue sweep (daily)
- Journey → screen map
End-to-end journeys mapped to exact endpoints. Write actions are server-confirmed (no optimistic mutations); conflicts (409) are surfaced as banners, never silent.
1. Add a book to the catalog (librarian/catalog admin)
Discover missing title → [Book form] → POST /api/v1/books (library.controller.ts:27-31)
→ 201 BookDoc → navigate to detail
→ 409 "Book with ISBN ... already exists." (library.service.ts:37-39) → inline error, edit ISBN
→ 400 → field errors (title/author/isbn required: create-book.dto.ts:5-15)
- Copies default to 1;
availableCopiesmirrorstotalCopies(library.service.ts:41-46). - Bulk entry (many titles) is
(planned)via catalog import (IMPLEMENTATION_PLAN.md:172,228).
2. Find a book (student / librarian)
Search box (title/author/ISBN) → GET /api/v1/books?q=…&page=1&limit=20 (library.controller.ts:33-41)
→ server regex match (book.repository.ts:26-30) → list rows with availability badge
→ GET /api/v1/books/:id (library.controller.ts:43-47) → detail
- No filter → full catalog sorted by title asc (
library.service.ts:68-74). - Category/publisher/status filtering is client-side only (OQ-5).
3. Issue a book to a student (librarian)
Book detail (available) → [Issue form] (student picker + due date)
→ POST /api/v1/books/issue {bookId, studentId, dueDate, notes?} (library.controller.ts:61-65)
→ 201 BorrowRecordDoc (ACTIVE, borrowedAt=now, dueDate as sent) → success → refresh detail
→ 409 "No copies available for borrowing." (library.service.ts:109-111) → availability refreshed
→ 409 "Student already has maximum number of borrowed books." (library.service.ts:116-120) → show count
→ 404 → book deleted meanwhile → back to catalog
- Side effect:
BookIssueddomain event (library.service.ts:141-148).
4. Return a book, settle the fine (librarian + bursar)
Borrowed-by-me / active list → select record → [Return form]
→ POST /api/v1/books/return {borrowRecordId, fineAmount?, notes?} (library.controller.ts:67-71)
→ server computes fine = ceil(overdueDays) × 5 (library.service.ts:159,211-217) or uses override
→ 201/200 record: status=RETURNED, returnedAt=now, fineAmount, fineStatus=pending|undefined
→ if fine > 0 → [Pay fine] → POST /api/v1/books/fines/:borrowRecordId/pay (library.controller.ts:85-89)
→ fineStatus: PAID (library.service.ts:203-209)
→ 409 "Book was not actively borrowed." (library.service.ts:155-157) → refresh, record already closed
- Side effect:
BookReturnedevent withfineAmount(library.service.ts:180-191).
5. Student checks their loans (student / parent)
My loans → GET /api/v1/books/borrows/:studentId/active (library.controller.ts:79-83)
→ active list (populated bookId; borrow-record.repository.ts:31) → cards with due dates
→ GET /api/v1/books/borrows/:studentId (library.controller.ts:73-77) → full history (returned incl.)
- Overdue detection is client-derived (server has no worker):
now > dueDate && status == active→ overdue badge + computed fine preview (OQ-2). studentIdcomes from the student profile state, not ameendpoint (OQ-9).
6. Librarian's overdue sweep (daily)
Overdue view → client asks active borrows for all students? NO — server lacks a list-all-active endpoint.
Fallback today: per-student active lists from borrowed-by-me screens;
a server overdue scan + endpoint is (planned) (borrow-record.repository.ts:34-43 exists, unused; IMPLEMENTATION_PLAN.md:228)
- Until then: librarian sees overdue only when a student returns a book (fine computed) or via the client's derived-overdue badge on a student's borrowed-by-me view.
Journey → screen map
| Journey | Screens (05_Screen_Inventory.md) |
|---|---|
| Add/edit book | 4 Catalog List → 5 Book Detail → 7 Book Form |
| Find book | 4 Catalog List (search) → 5 Book Detail |
| Issue | 5 Book Detail → 8 Issue Form |
| Return + fine | 6 Borrowed-by-Me → 9 Return Form → fine pay on record |
| Student loans check | 6 Borrowed-by-Me (active) → history tab |
| Overdue sweep | 10 Overdue View (derived) |
04 — Information Architecture (Library Module)
- 1. Screen tree
- 2. Navigation rules
- 3. Data model (client views of server docs)
- 4. Labels & status vocabulary
- 5. Metadata & search surface
- 6. Cross-module references
Screen tree, route map, and data model binding for the library surface. Routes are relative to the app's authenticated shell (see 00-shared/05). Server documents:
Book(book.schema.ts:15-48),BorrowRecord(borrow-record.schema.ts:20-58).
1. Screen tree
/library
├── 4 Catalog List /library
│ └── 5 Book Detail /library/books/:id (from list row)
│ ├── 8 Issue Form /library/books/:id/issue (sheet on mobile)
│ └── 7 Book Form /library/books/:id/edit (edit; create from FAB)
├── 7 Book Form (create) /library/books/new (FAB on Catalog List)
├── 6 Borrowed-by-Me /library/my-borrows
│ └── 9 Return Form /library/my-borrows/return/:recordId (sheet)
├── 10 Overdue View /library/overdue (derived; server scan planned)
└── Fine pay inline action on any borrow record row (sheet confirm)
2. Navigation rules
- Catalog List is the library home; search is in-place (no separate screen).
- Book Detail is the hub: view metadata, availability, active loans
(planned)— it routes to Issue (if available), Edit (if permitted). - Borrowed-by-Me is a two-tab screen: Active (
status=active) / History (status=returned, newest first —library.service.ts:196sortsborrowedAtdesc). - Overdue View is a derived list of active records where
now > dueDate; gated to staff (books.issue/books.return). Server-side scan(planned). - No deep-linking to a borrow record route exists server-side; client uses record id as the key.
3. Data model (client views of server docs)
Book (book.schema.ts:15-48, base.schema.ts):
Book { id, tenantId, title, author, isbn,
publisher?, category?, edition?,
totalCopies, availableCopies,
status: available|borrowed|damaged|lost, // book.schema.ts:7-12,40-41
shelfLocation?, description?,
createdAt, updatedAt, version }
- Derived client fields:
availability = availableCopies,isAvailable = availableCopies > 0,copyBadge = "$availableCopies/$totalCopies".
BorrowRecord (borrow-record.schema.ts:20-58):
BorrowRecord { id, tenantId,
bookId (populated → Book by repo: borrow-record.repository.ts:31,42),
studentId (ref Student — NOT populated by any repo method today),
issuedBy? (ref User — never written: library.service.ts:132-139),
borrowedAt, dueDate, returnedAt?,
status: active|returned|overdue|lost, // :40-45
fineAmount, fineStatus?: pending|paid|waived, // :47-54
notes? }
- Derived client fields:
isOverdue = status==active && now > dueDate,finePreview = ceil((now - dueDate)/day) × 5(mirrors server rulelibrary.service.ts:211-217; authoritative only after return).
4. Labels & status vocabulary
| Server enum | Value | UI badge | Where |
|---|---|---|---|
BookStatus | available / borrowed / damaged / lost | green / amber (borrowed) / red / grey | book.schema.ts:7-12 |
BorrowStatus | active / returned / overdue / lost | blue / grey / red / red | borrow-record.schema.ts:7-12 |
FineStatus | pending / paid / waived | amber / green / grey | borrow-record.schema.ts:14-18 |
overdueandloston borrow records are never written by any endpoint today (OQ-2/OQ-4) — client derives overdue; lost is future.- Fine amounts have no currency contract (plain
Number, rate 5/day,library.service.ts:25) — render as plain units.
5. Metadata & search surface
- Server search:
qregex over title/author/ISBN, case-insensitive (book.repository.ts:26-30); paginationpage/limitviabuildPaginationMeta(library.service.ts:75). - Client-side filters only: category, publisher, status, availability (OQ-5).
- Sort: title asc server-side default (
book.repository.ts:36); nosortquery param onGET /books— client re-sorts in memory.
6. Cross-module references
studentId→ Students module profile (client pulls id from student state; OQ-9).issuedBy→ Users module (field dead today, OQ-3).- Events
BookIssued/BookReturned(library.service.ts:141-148,180-191) → notifications pipeline(proposed). - Covers: Files module (
files.controller.ts:29-71) exists; no cover field on Book (OQ-8) — cover attachments(proposed).
05 — Screen Inventory (Library Module)
- Legend
- 1. Catalog List + Search (
/library) - 2. Book Detail (
/library/books/:id) - 3. Book Form — create (
/library/books/new) & edit (/library/books/:id/edit) - 4. Issue Form (
/library/books/:id/issue— sheet on mobile) - 5. Borrowed-by-Me — Active & History (
/library/my-borrows) - 6. Return Form (
/library/my-borrows/return/:recordId— sheet) - 7. Overdue View (
/library/overdue) - 8. Fine Pay (inline on any borrow record with
fineStatus=pending) - Shared components used
- Analytics events (proposed)
- Keyboard, landscape, tablet, desktop
Every screen of the Library module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics.
Legend
States = idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission.
Analytics events follow {module}.{screen}.{action} (proposed; SDK open — 00-shared/10 §8).
1. Catalog List + Search (/library)
| Field | Detail |
|---|---|
| Purpose | Browse and search the book catalog with live availability |
| Entry | library tab/home; after create/edit/issue/return (refresh on focus) |
| Exit | → book detail (row); → book form (FAB) |
| Source | GET /api/v1/books?page&limit&q (library.controller.ts:33-41) — server regex on title/author/ISBN (book.repository.ts:26-30) |
| Widgets | AppSearchBar (debounced q), AppListTile/AppCard per book (title, author, availability badge $available/$total), AppFAB "Add book", AppSkeleton, AppEmptyState ("No books match…"), AppPagination (infinite scroll) |
| Filters | client-side chips only: category, status, availability (OQ-5) |
| Permissions | read requires books.read (enforcement (planned)); FAB hidden without books.create |
| States | loading, empty, error(offline/5xx/429), idle; permission state (hidden FAB) |
| Analytics | library.catalog.search, library.catalog.open_book (proposed) |
| Realtime | re-fetch on focus; WS (planned) |
2. Book Detail (/library/books/:id)
| Field | Detail |
|---|---|
| Purpose | Full metadata, availability, and entry point to issue/edit |
| Source | GET /api/v1/books/:id (library.controller.ts:43-47) → 404 RESOURCE_NOT_FOUND (library.service.ts:80) |
| Widgets | AppCard header (title, author, ISBN), metadata rows (publisher, category, edition, shelfLocation, description), AppBadge status + $available/$total copies, action buttons |
| Actions | Issue (enabled iff availableCopies >= 1, books.issue), Edit (books.update), Delete (books.delete, confirm dialog) |
| States | loading skeleton, 404 → AppEmptyState + back, permission-based action hiding |
| Analytics | library.book.open, library.book.issue_start (proposed) |
3. Book Form — create (/library/books/new) & edit (/library/books/:id/edit)
| Field | Detail |
|---|---|
| Purpose | Add a book (FAB) or edit metadata/copies |
| Source | create: POST /api/v1/books (library.controller.ts:27-31); edit: PATCH /api/v1/books/:id (library.controller.ts:49-53) |
| Form | title*, author*, isbn*, publisher?, category?, edition?, totalCopies?, shelfLocation?, description? (create-book.dto.ts:5-46; update = same fields optional, update-book.dto.ts:4) |
| Errors | 409 duplicate ISBN inline (library.service.ts:37-39); 400 field errors; 404 on edit |
| Permissions | books.create / books.update |
| Analytics | library.book.create, library.book.update (proposed) |
4. Issue Form (/library/books/:id/issue — sheet on mobile)
| Field | Detail |
|---|---|
| Purpose | Lend a copy to a student with an explicit due date |
| Source | POST /api/v1/books/issue (library.controller.ts:61-65) |
| Form | student (picker from Students module), dueDate* (date picker, must be future), notes? (issue-book.dto.ts:5-20) |
| Conflicts | 409 no copies (library.service.ts:109-111), 409 max 5 active (library.service.ts:116-120) → banners + refresh |
| Permissions | books.issue |
| Side effect | BookIssued event (library.service.ts:141-148) |
| Analytics | library.issue.submit (proposed) |
5. Borrowed-by-Me — Active & History (/library/my-borrows)
| Field | Detail |
|---|---|
| Purpose | A student's current loans and full history (staff use for lookups too) |
| Source | active: GET /api/v1/books/borrows/:studentId/active (library.controller.ts:79-83); history: GET /api/v1/books/borrows/:studentId (library.controller.ts:73-77, sorted borrowedAt desc, library.service.ts:196) |
| Widgets | two tabs; per record AppCard: book title (populated bookId), borrowedAt, dueDate (prominent), returnedAt, fine chip |
| Derived | overdue badge + fine preview when active && now > dueDate (OQ-2) |
| Permissions | books.read; studentId from profile (OQ-9) |
| Analytics | library.myborrows.open (proposed) |
6. Return Form (/library/my-borrows/return/:recordId — sheet)
| Field | Detail |
|---|---|
| Purpose | Close an ACTIVE borrow; compute or override the fine |
| Source | POST /api/v1/books/return (library.controller.ts:67-71) |
| Form | fineAmount? (prefilled with server-rule preview ceil(days) × 5, editable by staff), notes? (return-book.dto.ts:5-18) |
| Conflicts | 409 "Book was not actively borrowed." (library.service.ts:155-157) → refresh |
| Permissions | books.return |
| Side effect | BookReturned event with fineAmount (library.service.ts:180-191) |
| Analytics | library.return.submit, library.fine.pay (proposed) |
7. Overdue View (/library/overdue)
| Field | Detail |
|---|---|
| Purpose | Staff sweep of late loans |
| Source | client-derived today: from Borrowed-by-Me / per-student active lists, filter now > dueDate; server findOverdue() exists unused (borrow-record.repository.ts:34-43); dedicated endpoint + scan (planned) (IMPLEMENTATION_PLAN.md:228) |
| Widgets | list of overdue cards: student, book, days late, fine preview, "Return" shortcut |
| Permissions | staff (books.issue/books.return) |
| Note | This screen is honest about its data: shows "(derived — server scan planned)" |
| Analytics | library.overdue.open (proposed) |
8. Fine Pay (inline on any borrow record with fineStatus=pending)
| Field | Detail |
|---|---|
| Purpose | Mark a fine paid |
| Source | POST /api/v1/books/fines/:borrowRecordId/pay (library.controller.ts:85-89) → fineStatus: PAID (library.service.ts:203-209) |
| Widgets | confirm AppDialog showing fineAmount → snackbar success |
| Permissions | fines.pay |
| Note | server does not guard state (OQ-6) — client only shows the button on fineStatus == pending records |
Shared components used
AppTextField, AppButton, AppSnackbar, AppCard, AppListTile, AppBottomSheet,
AppDialog, AppMenu, AppSkeleton, AppEmptyState, AppOfflineBanner, AppFAB,
AppBadge, AppChips, AppSearchBar, AppAvatar, AppDateField, AppPicker.
Module-specific: availabilityBadge, fineChip, overdueTag, copyStepper —
defined in 07_Component_Library.md.
Analytics events (proposed)
library.catalog.{search,open_book}, library.book.{create,update,delete,issue_start},
library.issue.{submit,conflict}, library.return.{submit,conflict},
library.fine.{pay,pay_conflict}, library.myborrows.open, library.overdue.open (all proposed).
Keyboard, landscape, tablet, desktop
- Forms are sheets on phone, centered dialogs on tablet/desktop (≤ 480 dp, 00-shared/03).
- Catalog List: phone single column; tablet/desktop two-column list/detail master-detail.
- Date fields use
AppDateField(native picker); no manual date typing.
06 — Screen Specifications (Library Module)
- S1. Catalog List + Search (
/library) - S2. Book Detail (
/library/books/:id) - S3. Book Form — Create / Edit (
/library/books/new,/library/books/:id/edit) - S4. Issue Form (
/library/books/:id/issue— bottom sheet) - S5. Borrowed-by-Me — Active / History (
/library/my-borrows) - S6. Return Form (
/library/my-borrows/return/:recordId— bottom sheet) - S7. Overdue View (
/library/overdue)
Full functional spec per screen: layout, states, copy, keyboard, a11y, motion, data bindings and error mapping. Wire contracts in 12_API_Mapping.md; components in 07_Component_Library.md; Cubit behavior in 13_State_Management.md.
S1. Catalog List + Search (/library)
Layout (portrait phone)
┌───────────────────────────────────────────┐
│ AppBar "Library" [avatar] │
│ ┌─ SearchBar q=… ─────────────────────┐ │
│ [chips] All · Category · Status · Avail │ │ (client-side filters, OQ-5)
│ ─────────────────────────────────────── │ │
│ [list] BookRow │ │
│ Cover-thumb? Title │ │
│ Author · ISBN │ │
│ [avail badge] [status] │ │
│ [list] … (infinite scroll) │ │
│ ─────────────────────────────────────── │ │
│ [FAB +] │ │
└───────────────────────────────────────────┘
Behavior
| Aspect | Spec |
|---|---|
| Search | AppSearchBar, debounce 300 ms → q; server regex title/author/ISBN (book.repository.ts:26-30); clearing returns full list (library.service.ts:57-74) |
| Row | BookRow: title (2 lines max), author · ISBN, right-aligned availabilityBadge ($available/$totalCopies) + BookStatus badge |
| Pagination | infinite scroll, page/limit=20; meta.hasNext from buildPaginationMeta (library.service.ts:75) stops the loader |
| Filters | chips filter in memory (server supports no status/category filter — OQ-5); combos: category, status, availability (availableCopies > 0) |
| Empty | "No books found" + clear-search action; distinguish "no search results" vs "catalog empty" |
| Error | offline → AppOfflineBanner + cached list; 429 → countdown banner; 5xx → error state + retry (requestId in snackbar) |
| Permission | no books.read → empty state with lock copy (RBAC (planned), OQ-1); no books.create → FAB hidden |
| Refresh | pull-to-refresh → re-fetch page 1; focus-return also refreshes (issue/return may have changed availability) |
| a11y | search focused on screen open (large catalogs); results announced via live region; row semantics = button |
| Motion | list uses standard AppListTile transitions; search debounce uses m-fast progress shimmer (00-shared/08) |
| Analytics | library.catalog.search, library.catalog.open_book (proposed) |
States table
| State | UI |
|---|---|
| idle | cached list or empty |
| loading | AppSkeleton rows (first load); shimmer under search (subsequent) |
| empty | AppEmptyState "No books found" |
| error | error panel + retry; offline banner when no cache |
| disabled | chips disabled while loading filters |
| permission | lock empty-state (RBAC planned) |
S2. Book Detail (/library/books/:id)
Layout
┌───────────────────────────────────────────┐
│ ← Detail [⋯ menu] │
│ ┌───────────────────────────────────────┐ │
│ │ [cover?] Title (headline) │ │
│ │ Author — ISBN │ │
│ │ [status badge] [avail badge 3/5] │ │
│ └───────────────────────────────────────┘ │
│ Metadata: Publisher / Category / Edition │
│ Shelf / Added (createdAt) │
│ Description (scrollable) │
│ ──────────────────────────────────────── │
│ [Issue book] (filled, enabled iff avail)│
│ [Edit] (outlined, books.update) │
└───────────────────────────────────────────┘
Behavior
| Aspect | Spec |
|---|---|
| Load | GET /books/:id (library.controller.ts:43-47); 404 → AppEmptyState "Book not found" + back (library.service.ts:80) |
| Availability | availableCopies > 0 → issue enabled; 0 → disabled + copy "All copies are on loan" (issue is a server 409 too, library.service.ts:109-111) |
| Status copy | damaged/lost (book.schema.ts:7-12) → issue disabled + warning badge (no endpoint transitions to these — OQ-4) |
| Edit | PATCH flow (S4); after save, detail re-fetches |
| Delete | menu → AppDialog confirm → DELETE /books/:id (library.controller.ts:55-59); 409 "Cannot delete book with active borrow records." (library.service.ts:99-101) → dialog explains active loans; 200 → snackbar + pop to catalog (soft delete, library.service.ts:103) |
| Edit copies | copy delta adjusts availableCopies (floored at 0) (library.service.ts:87-90) — surface "available will change by same amount" note |
| Active loans | not exposed by any endpoint — skip section (planned) |
| a11y | status badges read as full sentences ("3 of 5 copies available"); actions grouped |
| Motion | hero transition from list row (cover/title) m-base (00-shared/08) |
| Analytics | library.book.open, library.book.issue_start, library.book.delete (proposed) |
S3. Book Form — Create / Edit (/library/books/new, /library/books/:id/edit)
Layout
┌───────────────────────────────────────────┐
│ ← New book / Edit book [Save] │
│ Title* ────────────── │
│ Author* ────────────── │
│ ISBN* ────────────── │
│ Publisher ────────────── │
│ Category ────────────── │
│ Edition ────────────── │
│ Copies [− 3 +] (create only*: total) │
│ Shelf ────────────── │
│ Description ────────────── │
│ (multiline, optional) │
└───────────────────────────────────────────┘
Behavior
| Aspect | Spec |
|---|---|
| Fields | CreateBookDto (create-book.dto.ts:5-46): title/author/isbn required strings; publisher/category/edition/shelfLocation/description optional; totalCopies optional number @Min(1) default 1 (:32-36); edit uses UpdateBookDto = PartialType (update-book.dto.ts:4) — all optional, copy-count editable |
| Copies semantics | create: availableCopies = totalCopies (library.service.ts:41-46); edit: server shifts availableCopies by the delta, floor 0 (library.service.ts:87-90) — show the projected availability inline |
| Validation | client mirrors server: required title/author/isbn; ISBN numeric-ish; copies ≥ 1; 400 maps details[].message → fields |
| Conflict | 409 duplicate ISBN (library.service.ts:37-39) → inline under ISBN: "A book with this ISBN already exists" + link to the existing book if client has it in cache |
| Save | create POST /books → 201 → push detail; edit PATCH /books/:id → 200 → pop with updated doc; no optimistic writes |
| Draft | unsaved-changes guard on back; draft persist (proposed) |
| a11y | every field labelFor-linked; first invalid field focused on 400 |
| Analytics | library.book.create, library.book.update (proposed) |
S4. Issue Form (/library/books/:id/issue — bottom sheet)
Layout
┌───────────────────────────────────────────┐
│ Issue: "Book Title" [Confirm] │
│ Student* [picker ▾] (search + select) │
│ Due date* [date ▾] (default +14 days) │
│ Notes ────────────── │
│ ──────────────────────────────────────── │
│ Availability: 3 of 5 copies available │
└───────────────────────────────────────────┘
Behavior
| Aspect | Spec |
|---|---|
| Student picker | from Students module profile state; free-text search, one selection; disabled while no student selected |
| Due date | AppDateField; must be future (client blocks past dates; server has no min — server accepts any date issue-book.dto.ts:14-15, so client must enforce) |
| Submit | POST /books/issue {bookId, studentId, dueDate, notes?} (library.controller.ts:61-65) |
| 201 | record status=ACTIVE, borrowedAt=now (library.service.ts:132-139) → close sheet, snackbar "Issued to {student}", refresh detail + my-borrows |
| 409 no copies | banner "No copies available for borrowing." + re-fetch book (library.service.ts:109-111) |
| 409 max 5 | banner "Student already has maximum number of borrowed books." + show their active count from cached my-borrows (library.service.ts:116-120) |
| 404 | book deleted meanwhile → close sheet, pop detail with snackbar |
| Busy | Confirm shows spinner; sheet not dismissible during submit (prevents double-tap — see QA-2) |
| a11y | sheet is modal, focus first field; server messages read via live region |
| Analytics | library.issue.submit, library.issue.conflict(kind) (proposed) |
S5. Borrowed-by-Me — Active / History (/library/my-borrows)
Layout
┌───────────────────────────────────────────┐
│ ← My borrows [overdue▾]│
│ [Active (2)] [History (11)] │
│ ──────────────────────────────────────── │
│ [Card] Book Title │
│ borrowed 12 Jul · due 26 Jul ← 4d [!!] │
│ fine 20 (pending) [Return] │
│ [Card] … │
└───────────────────────────────────────────┘
Behavior
| Aspect | Spec |
|---|---|
| Tabs | Active = GET /books/borrows/:studentId/active (library.controller.ts:79-83); History = GET /books/borrows/:studentId (library.controller.ts:73-77) — includes returned + any active; not paginated (library.service.ts:195-201); client shows first N with "load more" (proposed) |
| Populate | repo populates bookId (borrow-record.repository.ts:31,42) → title/author render; studentId is not populated — student identity comes from the profile context (OQ-9) |
| Due date | prominent on active cards; overdueTag when active && now > dueDate (derived, OQ-2); fine preview ceil(days)×5 shown but marked "estimate" |
| Fine chip | fineStatus == pending → amber chip with amount + "Pay" (fines.pay); paid → green; waived → grey (enum borrow-record.schema.ts:14-18) |
| Return | staff sees [Return] on active cards → Return Form (S6); student/parent do not |
| History rows | returnedAt + final fineAmount; no actions |
| Empty | active: "Nothing on loan right now"; history: "No past borrows" |
| Refresh | pull-to-refresh re-fetches both tabs |
| a11y | overdue announced ("overdue by 4 days"); fine chips full-sentence semantics |
| Analytics | library.myborrows.open (proposed) |
S6. Return Form (/library/my-borrows/return/:recordId — bottom sheet)
Layout
┌───────────────────────────────────────────┐
│ Return: "Book Title" [Confirm] │
│ Borrower: Student Name │
│ Due: 26 Jul · Returned today │
│ Days overdue: 4 │
│ Fine (auto) [20] (editable, staff) │
│ Notes ────────────── │
│ ──────────────────────────────────────── │
│ Fine preview = 4 days × 5/day (auto) │
└───────────────────────────────────────────┘
Behavior
| Aspect | Spec |
|---|---|
| Prefill | days overdue = ceil((now − dueDate)/day); fine preview = days × 5 — client mirrors server rule library.service.ts:211-217; server recomputes anyway (:159) |
| Override | fineAmount? editable @Min(0) (return-book.dto.ts:9-13) — server prefers dto.fineAmount ?? calculateFine(dueDate) (library.service.ts:159); keep override only for staff (hide for student/parent — they can't return anyway) |
| Submit | POST /books/return {borrowRecordId, fineAmount?, notes?} (library.controller.ts:67-71) |
| 200 | record status=RETURNED, returnedAt=now, fineAmount, fineStatus = pending if fine > 0 else unset (library.service.ts:161-169); book availableCopies+1, status available (library.service.ts:172-178) |
| 409 | "Book was not actively borrowed." (library.service.ts:155-157) → refresh record, banner "Already returned" |
| Fine pay | if fineAmount > 0 → inline secondary action "Pay fine" → confirm dialog → POST /books/fines/:id/pay (library.controller.ts:85-89) → chip → paid (library.service.ts:203-209) |
| Busy | spinner on Confirm; no dismiss during submit |
| a11y | fine amount + status live-regioned after submit |
| Analytics | library.return.submit, library.fine.pay (proposed) |
S7. Overdue View (/library/overdue)
Layout
┌───────────────────────────────────────────┐
│ ← Overdue [refresh] │
│ ⚠ Derived from client data — server │
│ overdue scan planned │
│ [Card] Student · Book Title │
│ due 12 Jul · 15 days late · fine 75 │
│ [Return] │
└───────────────────────────────────────────┘
Behavior
| Aspect | Spec |
|---|---|
| Data | derived by filtering active borrows where now > dueDate (per-student active lists; no list-all endpoint — OQ-2). Server findOverdue() exists but is unused (borrow-record.repository.ts:34-43); a dedicated endpoint + scheduled scan is (planned) (IMPLEMENTATION_PLAN.md:228) |
| Honesty | persistent info banner: "Derived from client data — server overdue scan planned" |
| Rows | student (from profile state), book title, dueDate, days late, fine preview ceil(days)×5, [Return] shortcut → S6 |
| Empty | "Nothing overdue" |
| Refresh | re-fetches the underlying active lists |
| Permission | staff only (books.issue/books.return); hidden otherwise |
| Analytics | library.overdue.open (proposed) |
07 — Component Library (Library Module)
- 1.
AvailabilityBadge - 2.
FineChip - 3.
OverdueTag - 4.
CopyStepper - 5.
BookRow - 6.
BorrowRecordCard - 7.
StudentPicker - 8.
DueDateField - 9.
FineSummaryPanel - Shared components reused verbatim (00-shared/03)
- Component → screen matrix
Module-specific widgets on top of 00-shared/03. Each lists the server binding and the shared components it composes. All are pure-presentational; state lives in the Cubits (13_State_Management.md).
1. AvailabilityBadge
| Purpose | Show copy availability at a glance |
| Inputs | availableCopies, totalCopies, status: BookStatus |
| Renders | AppBadge; color by state: availableCopies > 0 → green "3/5"; 0 → red "0/5 — all on loan"; `status == damaged |
| Source | Book.availableCopies/totalCopies/status (book.schema.ts:34-41) |
| Used in | S1 rows, S2 header, S4 sheet footer |
2. FineChip
| Purpose | Show fine amount + status on borrow records |
| Inputs | fineAmount, fineStatus?: FineStatus |
| Renders | amber "20 · pending" (with optional Pay action), green "20 · paid", grey "waived" (borrow-record.schema.ts:14-18); no chip when fineStatus is unset (on-time returns, library.service.ts:166) |
| Source | BorrowRecord.fineAmount/fineStatus (borrow-record.schema.ts:47-54) |
| Used in | S5 cards, S6 result, S7 rows |
3. OverdueTag
| Purpose | Flag a late loan |
| Inputs | dueDate, borrowStatus |
| Logic | visible iff borrowStatus == 'active' && now > dueDate — client-derived; server never writes BorrowStatus.OVERDUE today (OQ-2, borrow-record.schema.ts:9-11) |
| Renders | red AppBadge "Overdue by N d" (+ optional fine estimate) |
| a11y | semantics "overdue by N days" |
| Used in | S5, S7 |
4. CopyStepper
| Purpose | Adjust totalCopies in the book form |
| Inputs | totalCopies, availableCopies, `mode: create |
| Renders | [−] 3 [+] with live projection: create → available = total (library.service.ts:41-46); edit → available = max(0, available + Δ) (library.service.ts:87-90) |
| Validation | @Min(1) mirrors create-book.dto.ts:34-35; floor 0 on available |
| Used in | S3 |
5. BookRow
| Purpose | Catalog list row |
| Composition | cover thumb (or letter avatar), title (max 2 lines), "author · ISBN", AvailabilityBadge + status badge |
| Source | Book doc (book.schema.ts:15-48) |
| Used in | S1, S7 (with student line) |
6. BorrowRecordCard
| Purpose | One loan on the Borrowed-by-Me / Overdue screens |
| Composition | AppCard: book title (populated bookId, borrow-record.repository.ts:31,42), borrowedAt, dueDate (prominent), OverdueTag, FineChip, actions [Return] (staff) / [Pay] |
| Source | BorrowRecord doc (borrow-record.schema.ts:20-58) |
| Used in | S5, S6 trigger, S7 |
7. StudentPicker
| Purpose | Select a student when issuing |
| Composition | AppPicker over the Students profile cache; free-text search, single select |
| Source | Students module state (id → studentId in IssueBookDto, issue-book.dto.ts:7-8); no dedicated picker endpoint |
| Used in | S4 |
8. DueDateField
| Purpose | Pick a due date for issue |
| Composition | AppDateField (native date picker); client enforces future dates (server accepts any IsDateString — issue-book.dto.ts:14-15); default +14 days |
| Used in | S4, S6 (read-only display) |
9. FineSummaryPanel
| Purpose | Return-form fine computation explainer |
| Composition | "Days overdue: N → N × 5/day = 20" plus editable fineAmount (staff only) and note "server recomputes at return" |
| Source | rule library.service.ts:211-217, override return-book.dto.ts:9-13 |
| Used in | S6 |
Shared components reused verbatim (00-shared/03)
AppButton, AppTextField, AppSnackbar, AppCard, AppListTile, AppBottomSheet,
AppDialog, AppMenu, AppSkeleton, AppEmptyState, AppOfflineBanner, AppFAB,
AppBadge, AppChips, AppSearchBar, AppAvatar, AppDateField, AppPicker.
Component → screen matrix
| S1 | S2 | S3 | S4 | S5 | S6 | S7 | |
|---|---|---|---|---|---|---|---|
| AvailabilityBadge | ✓ | ✓ | ✓ | ||||
| FineChip | ✓ | ✓ | ✓ | ||||
| OverdueTag | ✓ | ✓ | ✓ | ||||
| CopyStepper | ✓ | ||||||
| BookRow | ✓ | ✓ | |||||
| BorrowRecordCard | ✓ | ✓ | ✓ | ||||
| StudentPicker | ✓ | ||||||
| DueDateField | ✓ | ||||||
| FineSummaryPanel | ✓ |
08 — Form Specifications (Library Module)
Field-level contracts for the three forms. Server validation is authoritative; client validation mirrors it for latency only. DTO sources:
create-book.dto.ts,update-book.dto.ts,issue-book.dto.ts,return-book.dto.ts.
F1. Book form (create / edit)
Create body = CreateBookDto (create-book.dto.ts:4-47); edit body = UpdateBookDto
= PartialType(CreateBookDto) (update-book.dto.ts:4) — every field optional on edit.
| Field | Type | Required | Constraints (server) | Notes |
|---|---|---|---|---|
title | string | ✓ | @IsString (:6-7) | trimmed server-side (book.schema.ts:16-17) |
author | string | ✓ | @IsString (:10-11) | trimmed (book.schema.ts:19-20) |
isbn | string | ✓ | @IsString (:14-15) | trimmed; unique per tenant — 409 on duplicate (library.service.ts:35-40, book.schema.ts:51) |
publisher | string | – | @IsOptional @IsString (:17-20) | |
category | string | – | @IsOptional @IsString (:22-25) | client filter chip source (OQ-5) |
edition | string | – | @IsOptional @IsString (:27-30) | |
totalCopies | number | – | @IsOptional @IsNumber @Min(1) (:32-36) | default 1 (library.service.ts:41); create sets availableCopies = totalCopies (:44-46); edit shifts availableCopies by Δ, floor 0 (library.service.ts:87-90) |
shelfLocation | string | – | @IsOptional @IsString (:38-41) | |
description | string | – | @IsOptional @IsString (:43-46) |
Validation order on submit: required → type → ISBN uniqueness hint (client may pre-check from cache, but the server 409 is authoritative).
Copy projection widget: create → "Available will equal total (N)"; edit → "Available: 2 → 3" using the Δ rule.
F2. Issue form
Body = IssueBookDto (issue-book.dto.ts:4-21).
| Field | Type | Required | Constraints (server) | Notes |
|---|---|---|---|---|
bookId | string | ✓ | @IsString (:6-7) | from Book Detail context; 404 if unknown (library.service.ts:108) |
studentId | string | ✓ | @IsString (:9-10) | from StudentPicker; ref Student (borrow-record.schema.ts:25-26) |
dueDate | string (ISO) | ✓ | @IsDateString (:14-15) | no server min-date rule — client must require future dates; new Date(dueDate) stored (library.service.ts:135) |
notes | string | – | @IsOptional @IsString (:17-20) | stored on record (library.service.ts:138) |
Server-side gates (client shows the same copy pre-submit where possible):
| Gate | Condition | Error |
|---|---|---|
| Availability | book.availableCopies < 1 | 409 "No copies available for borrowing." (library.service.ts:109-111) |
| Student cap | active borrows ≥ 5 | 409 "Student already has maximum number of borrowed books." (library.service.ts:116-120) |
Defaults: due date = today + 14 days (client default only; no server default —
dueDate required).
On success: record created with status: ACTIVE, borrowedAt: now
(library.service.ts:132-139); book availableCopies − 1, status → borrowed when
0 remain (library.service.ts:122-130).
F3. Return form
Body = ReturnBookDto (return-book.dto.ts:4-19).
| Field | Type | Required | Constraints (server) | Notes |
|---|---|---|---|---|
borrowRecordId | string | ✓ | @IsString (:6-7) | 404 if unknown (library.service.ts:153-154); 409 if not ACTIVE (library.service.ts:155-157) |
fineAmount | number | – | @IsOptional @IsNumber @Min(0) (:9-13) | staff override: server prefers dto.fineAmount ?? calculateFine(dueDate) (library.service.ts:159); 0 is legal (on-time or waived-by-zero) |
notes | string | – | @IsOptional @IsString (:15-18) | replaces record notes on return (library.service.ts:167) |
Fine computation (server, authoritative):
calculateFine(dueDate): 0 if now <= dueDate; else ceil((now - dueDate) / 1 day) × 5
(library.service.ts:211-217; DAILY_FINE_RATE = 5 at :25)
On success: status → RETURNED, returnedAt: now, fineAmount set,
fineStatus → pending iff fineAmount > 0 (else left unset) (library.service.ts:161-169);
book availableCopies + 1, status → available (library.service.ts:172-178).
Post-return follow-up: if fineAmount > 0, offer "Pay fine" →
POST /books/fines/:borrowRecordId/pay (no body — library.controller.ts:85-89)
→ fineStatus: PAID (library.service.ts:203-209). Server does not guard pay state
(OQ-6): client only offers it on fineStatus == pending.
F4. Validation & error UX (all forms)
| Code | Meaning | UI |
|---|---|---|
400 VALIDATION_ERROR | details[].message per field | inline field errors, focus first invalid |
409 DUPLICATE_RESOURCE | ISBN dup / no copies / cap / not-active | banner + contextual action (edit ISBN / refresh availability) |
404 RESOURCE_NOT_FOUND | book/record gone | close form, snackbar |
429 RATE_LIMITED | api tier | countdown, no auto-retry |
| 5xx | server | generic + requestId, manual retry |
Envelope + codes: response-envelope.interceptor.ts:44-52, http-exception.filter.ts:28-34,74-78.
No optimistic writes in any form — every submit awaits the server response
(00-shared/07 §9).
09 — User Behaviour (Library Module)
- 1. The desk clerk needs speed, not romance
- 2. Availability is the first question
- 3. Due dates and fines are anxiety triggers
- 4. Staff override, student observes
- 5. Search fatigue
- 6. The overdue sweep is aspirational
- 7. Mistakes must be reversible or blocked
- 8. Numbers speak plainly
- 9. Identity comes from context
- 10. Concurrency realities (behavioural caution)
Observed/expected behaviour patterns and the design decisions they drive. References the personas in 02_User_Personas.md.
1. The desk clerk needs speed, not romance
- Librarians transact all day: issue → return → next. Every form must open in ≤ 2
taps and prefill aggressively (due date +14 d, student last-used
(proposed)). - Confirmation dialogs allowed only on destructive/irreversible actions (delete,
fine pay); issue/return get a single confirm in-form button with spinner
(
06 S4/S6). - Resulting rule: write paths are server-confirmed (00-shared/07 §9); the spinner is the confirmation.
2. Availability is the first question
- Students ask "is it in?" before "what is it?". Every catalog row shows
AvailabilityBadge(07 §1); Book Detail's primary CTA is Issue, disabled whenavailableCopies == 0(library.service.ts:109-111). - When a copy's availability changes elsewhere (another return), the client re-fetches on focus — no stale "available" claims.
3. Due dates and fines are anxiety triggers
- Active loans show dueDate as the dominant field, with days remaining
(
S5); overdue gets the redOverdueTag+ fine estimate. - Fine estimates are explicitly labeled "estimate — server computes at return"
because the server is authoritative (
library.service.ts:211-217) and the client clock may differ. - Never hide a fine:
FineChiprenderspending|paid|waivedfromborrow-record.schema.ts:14-18; absence of chip = on-time return.
4. Staff override, student observes
fineAmountoverride (return-book.dto.ts:9-13) is staff-only UI; students/parents see the server value only.- Pay action (
fines.pay,permissions.constants.ts:61) appears only onfineStatus == pending— the server doesn't guard it (OQ-6), so the client does.
5. Search fatigue
- Server search is title/author/ISBN regex (
book.repository.ts:26-30) — students type partial strings, so debounce 300 ms and never require exact matches. - Category/status/availability filters are client-side (OQ-5); chips must not imply server filtering.
6. The overdue sweep is aspirational
- Librarians expect a "who's late" screen. Today that data is client-derived per
student (OQ-2); the UI is honest about it with a persistent banner, and the server
scan is
(planned)(IMPLEMENTATION_PLAN.md:228).
7. Mistakes must be reversible or blocked
- Delete blocked while active loans exist (409,
library.service.ts:97-102) — the dialog says why. - Double-issue prevention: form buttons lock during submit; the server 409s when the
cap is hit (
library.service.ts:109-111,116-120) — see QA-2/QA-3. - No undo for issue/return (state is transactional); corrections go through the record's lifecycle (return then re-issue).
8. Numbers speak plainly
- Fines are plain units (rate 5/day,
library.service.ts:25); no currency symbol invented — render "20" with label "units" until a currency contract exists (OQ-1 in 01_Product_Overview.md).
9. Identity comes from context
- Borrows are keyed by
studentId(borrow-record.schema.ts:25-26); there is no "my borrows" endpoint (OQ-9). The client binds the current student from the Students module state — the borrowed-by-me screens are therefore context-aware, not global.
10. Concurrency realities (behavioural caution)
- Issue/return are read-then-write (
library.service.ts:108-139,153-178) — two desks issuing the last copy race. The client can't fix this; it must always re-read on 409 and never retry blindly. (QA-1/QA-3 in 14_QA_Checklist.md.)
10 — Interaction Specification (Library Module)
- 1. Global interaction rules
- 2. Screen-level interactions
- 3. Motion summary
- 4. Keyboard & adaptive
- 5. a11y (module)
- 6. Analytics (proposed)
Micro-interactions, motion, feedback and platform behavior per screen/component. Timing tokens (
m-fast/m-base/m-slow), a11y and adaptive rules come from 00-shared/08 and 00-shared/09; this file lists module specifics only.
1. Global interaction rules
| Rule | Detail |
|---|---|
| Write feedback | every write (create/update/issue/return/pay) ends in a snackbar with the server's message; failures show the error code + requestId |
| No optimistic writes | all mutations await server; no rollback UI (nothing to roll back) |
| Double-tap guard | every submit button shows a spinner and is disabled while in flight; sheets not dismissible during submit |
| 409 handling | never auto-retry; always refresh the affected entity and re-render |
| Pull-to-refresh | catalog + borrows screens; bypasses cache TTL |
| Focus refresh | returning to any screen re-fetches (availability changes elsewhere) |
| 429 | countdown banner; no silent retry |
| Offline | reads from cache (TTL below); writes blocked with offline banner — no offline queue for library |
2. Screen-level interactions
S1 Catalog List
- Search debounce 300 ms; typing triggers shimmer under the search bar; results swap
in with
m-fastfade; clearing the query re-fetches page 1. - Infinite scroll: loader row appears when
meta.hasNext(buildPaginationMeta,library.service.ts:75); failure at page N → inline "Retry" chip. - Chip filter changes re-filter the current list in memory (no server call — OQ-5); chips show active state.
- Row tap → hero transition to S2 (
m-base).
S2 Book Detail
- Menu: Edit / Delete. Delete →
AppDialog(destructive tone, "Delete book?") →DELETE /books/:id(library.controller.ts:55-59); 409 → dialog swaps copy to "This book still has N active loans" (count unavailable client-side — generic server message shown verbatim). - Issue CTA enabled iff
availableCopies >= 1; disabled state explains why ("All copies on loan" / "Book marked damaged"). - After issue/return elsewhere: focus refresh.
S3 Book Form
- Live
CopyStepperprojection (create:available = total; edit:available + Δ, floor 0 —library.service.ts:41-46,87-90). - Back with dirty form → unsaved-changes dialog.
- 409 duplicate ISBN → inline error + (if cached) link to existing book.
S4 Issue Sheet
StudentPickersearch-as-you-type; selection locks the row; due dateAppDateFielddefaults +14 d, min = tomorrow (client rule).- Submit → spinner on Confirm; success → sheet closes with check animation; the detail behind it refreshes to the new availability.
- 409 no-copies → banner + detail re-fetches; 409 cap → banner shows the student's current active count.
S5 Borrowed-by-Me
- Tabs (Active/History) keep separate scroll positions; badge counts on tabs.
OverdueTaganimates in (m-fastpop) when a card crossesnow > dueDate— computed on ticker, refreshed on any re-fetch (derived; OQ-2).- Row → optional detail sheet (record fields verbatim: borrowedAt, dueDate, returnedAt?, fineAmount, fineStatus?, notes?).
- Staff rows:
[Return]button → S6 sheet;[Pay]on pending fines → confirm dialog.
S6 Return Sheet
- Opens prefilled from the record; fine panel computes
days = ceil((now − dueDate)/day)anddays × 5live (mirrorslibrary.service.ts:211-217); staff may editfineAmount(@Min(0),return-book.dto.ts:9-13). - Submit → spinner → success snackbar "Returned · fine 20 pending" → chips update in the list behind.
- If fine > 0 → inline secondary "Pay fine" → confirm dialog → pay endpoint
(
library.controller.ts:85-89) → chip flips to paid. - 409 "Book was not actively borrowed." → banner + record refresh.
S7 Overdue View
- Persistent info banner (server scan planned — IMPLEMENTATION_PLAN.md:228);
list derived from active borrows;
[Return]reuses S6; empty state "Nothing overdue".
3. Motion summary
| Transition | Token | Trigger |
|---|---|---|
| List → detail | m-base hero | row tap |
| Sheet present (issue/return) | m-base slide-up | CTA tap |
| Availability badge change | m-fast fade | after issue/return refresh |
| Overdue tag appear | m-fast pop | ticker crosses due date |
| Snackbar | m-fast | all writes |
| Dialog | m-base | delete / pay confirm |
4. Keyboard & adaptive
- Forms: keyboard avoidance +
resizeToAvoidBottomInset; fields advance viaonSubmit(title → author → isbn → …). - Date fields: native pickers, no manual typing.
- Phone: sheets for issue/return; tablet/desktop: centered dialogs ≤ 480 dp and master-detail for catalog (list left, detail right).
5. a11y (module)
AvailabilityBadge/OverdueTag/FineChipexpose full-sentence semantics.- Server 409/404 messages announced via live region (never just color).
- All interactive rows are buttons with visible focus.
- Spinner states announced ("Submitting issue…").
6. Analytics (proposed)
library.catalog.search, library.book.open, library.issue.submit|conflict,
library.return.submit|conflict, library.fine.pay, library.overdue.open —
naming per 00-shared/10 §8 (SDK open).
11 — Design System Mapping (Library Module)
- 1. Semantic status → token mapping
- 2. Typography mapping
- 3. Spacing / shape / elevation
- 4. Iconography
- 5. Component instance map (00-shared/03)
- 6. Theming notes
How the module maps to tokens and components of 00-shared/02 (tokens), 00-shared/03 (components), 00-shared/04 (system). No new tokens are invented; status colors are the only semantic additions and they bind to existing palette semantics (success/warning/error/neutral).
1. Semantic status → token mapping
| Domain state | Semantics | Token class | Used on |
|---|---|---|---|
BookStatus.available | success | color-success | AvailabilityBadge |
BookStatus.borrowed | info/warning | color-info | Book status badge |
BookStatus.damaged / lost | error | color-error | Book status badge (issue disabled) |
BorrowStatus.active | info | color-info | record chip |
BorrowStatus.returned | neutral | color-neutral | history chip |
| Overdue (derived) | error | color-error + type-style-danger | OverdueTag |
FineStatus.pending | warning | color-warning | FineChip |
FineStatus.paid | success | color-success | FineChip |
FineStatus.waived | neutral | color-neutral | FineChip |
Enum sources: book.schema.ts:7-12, borrow-record.schema.ts:7-18.
2. Typography mapping
| UI element | Text style (tokens) |
|---|---|
| Book title (detail) | text-headline |
| Book title (row/card) | text-title |
| Author · ISBN (rows) | text-body muted |
| Metadata (publisher/category/edition/shelf) | text-body muted, labeled rows |
| Availability badge | text-label (bold), on color-* surfaces |
| Due date (active card) | text-body emphasized + type-style-danger when overdue |
| Fine amounts | text-label monospace-ish (numbers), tabular figures (proposed) |
| Info banner (overdue-derived notice) | text-caption on info surface |
3. Spacing / shape / elevation
- Catalog rows: standard list density (
space-smgutters); cards on wide layouts. - Sheets: standard bottom-sheet padding; forms ≤ 480 dp centered on large screens.
- Availability badge: pill shape (
shape-pill),size-sm. - Fine summary panel: inset panel
elevation-1with divider.
4. Iconography
| Icon | Meaning | Bindings |
|---|---|---|
menu_book / book | catalog rows, empty state | S1/S5 |
search | search bar | S1 |
add | FAB "Add book" | S1 |
swap_horiz / assignment | issue/return actions | S4/S6 |
event / calendar_today | due date display | S5 |
warning | overdue + derived-data banner | S7 |
payments / receipt | fine pay | S5/S6 |
check_circle | paid fine chip | S5/S6 |
5. Component instance map (00-shared/03)
| Shared component | Instance(s) in library | Module-specific override |
|---|---|---|
AppSearchBar | S1 catalog search | debounce 300 ms |
AppListTile | S1 rows (via BookRow) | trailing badges |
AppCard | S5/S7 BorrowRecordCard, S2 header | due-date emphasis |
AppBadge | status/availability/fine chips | color mapping per §1 |
AppBottomSheet | S4 issue, S6 return | non-dismissible while submitting |
AppDialog | delete confirm, fine-pay confirm | destructive tone on delete |
AppFAB | S1 "Add book" | gated by books.create |
AppEmptyState | no books / no borrows / 404 | per-state copy |
AppSkeleton | list + detail loading | book-shaped rows |
AppOfflineBanner | all reads | cache fallback |
AppDateField | S4 due date | min = tomorrow, default +14 d |
AppPicker | S4 StudentPicker | searchable |
AppMenu | S2 overflow (Edit/Delete) | permission-gated entries |
6. Theming notes
- No library-specific theme; all screens consume the global tokens; status colors derive from existing semantic classes (§1) so dark mode/contrast come free (00-shared/02).
- Fine "units" rendering: plain numerals, no currency glyph (no server currency
contract —
library.service.ts:25).
12 — API Mapping (Library Module)
- 0. Module-wide request envelope & client policy
- Screen: Catalog list / search —
GET /books - Screen: Book detail —
GET /books/:id - Screen: Create book —
POST /books - Screen: Edit book —
PATCH /books/:id - Screen: Delete book —
DELETE /books/:id - Screen: Issue form —
POST /books/issue - Screen: Return form —
POST /books/return - Screen: Borrowed-by-me — history & active
- Screen: Fine pay —
POST /books/fines/:borrowRecordId/pay - Not exposed (gaps the client must work around)
- Loading / streaming / realtime
- Client-side error mapping (module)
- Optimistic / undo
- Notifications surface
Exact wire contract for every screen → endpoint. Base
/api/v1; envelope per 00-shared/07. All endpoints fromsrc/modules/library/library.controller.ts(@Controller('books'),:23); business rules fromlibrary.service.ts. Guards:@UseGuards(JwtAuthGuard)at controller level (library.controller.ts:22) — no RBAC metadata (OQ-1). Tenant from JWT only; never in body (base.repository.tsscoped filters).
0. Module-wide request envelope & client policy
| Aspect | Contract |
|---|---|
| Base | https://api.<domain>/api/v1 |
| Headers | Authorization: Bearer <accessToken>; x-request-id; Content-Type: application/json |
| Response | {success, message:"OK", data, meta?, timestamp, requestId} (response-envelope.interceptor.ts:44-52) |
| Error | {success:false, message, error:{code, details?}, timestamp, requestId} (http-exception.filter.ts:17-24,74-78); codes include VALIDATION_ERROR (400), DUPLICATE_RESOURCE (409), RATE_LIMITED (429), RESOURCE_NOT_FOUND (404) (:28-34) |
| Pagination | GET /books only: page (≥1, default 1), limit (default 20) (library.controller.ts:36-38); meta = {page, limit, totalItems, totalPages, hasNext, hasPrevious} via buildPaginationMeta (library.service.ts:75); borrow endpoints are not paginated (library.service.ts:195-201) |
| Caching | reads cached client-side (catalog 5 min, borrows 1 min, detail 1 min); no cache on write responses |
| Offline | reads from cache; writes blocked (no offline queue) |
| Retry | backoff on 5xx/network; no auto-retry on 409 or 429 |
| Idempotency | none server-side (issue/return are not idempotent; double-tap guarded client-side — QA-2) |
Screen: Catalog list / search — GET /books
| Endpoint | GET /api/v1/books?page&limit&q (library.controller.ts:33-41) |
| Query | page (default 1), limit (default 20) — Number() cast (:40); q optional |
| Success | 200 data: [BookDoc…] + meta; with q → regex title/author/ISBN case-insensitive (book.repository.ts:26-30); without → all, sort: {title: 1} (:36); skip (page−1)*limit (library.service.ts:59,71) |
| Count | matched via $or regex or {} (library.service.ts:60-67,73) |
| Errors | 400 invalid ints; 5xx |
| Filters | category/status/availability not supported server-side — client-side only (OQ-5) |
BookDoc (book.schema.ts:15-48, base.schema.ts):
_id, tenantId, title, author, isbn, publisher?, category?, edition?, totalCopies, availableCopies, status(available|borrowed|damaged|lost), shelfLocation?, description?, createdAt, updatedAt, version. Indexes: {tenantId,isbn} unique
(:51), {tenantId,title} (:52), {tenantId,author} (:53).
Screen: Book detail — GET /books/:id
| Endpoint | GET /api/v1/books/:id (library.controller.ts:43-47) |
| Success | 200 data: BookDoc |
| Errors | 404 RESOURCE_NOT_FOUND "Book not found." (library.service.ts:80); 400 invalid id (CastError → VALIDATION_ERROR) |
Screen: Create book — POST /books
| Endpoint | POST /api/v1/books (library.controller.ts:27-31) |
| Body | CreateBookDto (create-book.dto.ts:4-47): title*, author*, isbn*, publisher?, category?, edition?, totalCopies?(@Min 1), shelfLocation?, description? |
| Success | 201 data: BookDoc — totalCopies = dto.totalCopies ?? 1, availableCopies = totalCopies (library.service.ts:41-46) |
| Errors | 409 DUPLICATE_RESOURCE "Book with ISBN "…" already exists." (library.service.ts:37-39) + unique index {tenantId,isbn} (book.schema.ts:51); 400 validation |
Screen: Edit book — PATCH /books/:id
| Endpoint | PATCH /api/v1/books/:id (library.controller.ts:49-53) |
| Body | any subset of CreateBookDto fields (update-book.dto.ts:4) |
| Success | 200 data: BookDoc; if totalCopies changed → availableCopies = max(0, available + Δ) (library.service.ts:87-90) |
| Errors | 404 (library.service.ts:92); 400 validation; 409 duplicate ISBN if ISBN changed to an existing one |
Screen: Delete book — DELETE /books/:id
| Endpoint | DELETE /api/v1/books/:id (library.controller.ts:55-59) |
| Success | 200 — soft delete (library.service.ts:103 via BaseRepository.softDelete) |
| Errors | 409 "Cannot delete book with active borrow records." (library.service.ts:99-101); 404 (:104) |
Screen: Issue form — POST /books/issue
| Endpoint | POST /api/v1/books/issue (library.controller.ts:61-65) |
| Body | {bookId*, studentId*, dueDate* (ISO), notes?} (issue-book.dto.ts:4-21) |
| Success | 201 data: BorrowRecordDoc — status: ACTIVE, borrowedAt: now, dueDate as sent (library.service.ts:132-139); book availableCopies − 1, status → borrowed if 0 remain (:122-130) |
| Side effect | BookIssued {bookId, studentId} domain event (library.service.ts:141-148) |
| Errors | 404 book (:108); 409 "No copies available for borrowing." (:109-111); 409 "Student already has maximum number of borrowed books." (cap 5, :116-120); 400 validation |
| Note | no server min-date on dueDate — client enforces future; no idempotency key |
Screen: Return form — POST /books/return
| Endpoint | POST /api/v1/books/return (library.controller.ts:67-71) |
| Body | {borrowRecordId*, fineAmount?(@Min 0), notes?} (return-book.dto.ts:4-19) |
| Success | 200 data: BorrowRecordDoc — status: RETURNED, returnedAt: now, fineAmount = dto.fineAmount ?? calculateFine(dueDate), fineStatus = pending iff fineAmount > 0 else unset (library.service.ts:159-169); book availableCopies + 1, status → available (:172-178) |
| Side effect | BookReturned {bookId, studentId, fineAmount} event (library.service.ts:180-191) |
| Errors | 404 record (:153-154); 409 "Book was not actively borrowed." (:155-157); 400 validation |
| Note | fineAmount override is trusted server-side — staff-only UI (QA-4) |
BorrowRecordDoc (borrow-record.schema.ts:20-58):
_id, tenantId, bookId (populated by repos: borrow-record.repository.ts:31,42), studentId, issuedBy? (never written — OQ-3), borrowedAt, dueDate, returnedAt?, status(active|returned|overdue|lost), fineAmount, fineStatus?(pending|paid|waived), notes?, createdAt, updatedAt. Indexes (:61-63): {tenantId,bookId,studentId},
{tenantId,studentId,status}, {tenantId,dueDate,status}.
Screen: Borrowed-by-me — history & active
| History | GET /api/v1/books/borrows/:studentId (library.controller.ts:73-77) → 200 [BorrowRecordDoc…] sorted borrowedAt desc (library.service.ts:195-197); no pagination, no populate of studentId |
| Active | GET /api/v1/books/borrows/:studentId/active (library.controller.ts:79-83) → 200 [BorrowRecordDoc…] where status: ACTIVE (library.service.ts:199-201, borrow-record.repository.ts:24-32, bookId populated :31) |
| Errors | 400 invalid id; 5xx |
| Note | no me variant — client supplies studentId from profile (OQ-9) |
Screen: Fine pay — POST /books/fines/:borrowRecordId/pay
| Endpoint | POST /api/v1/books/fines/:borrowRecordId/pay (library.controller.ts:85-89) — no body |
| Success | 200 data: BorrowRecordDoc with fineStatus: PAID (library.service.ts:203-209) |
| Errors | 404 record (:207); 400 invalid id |
| Note | server does not check fineStatus before paying and does not validate fine > 0 (OQ-6) — client gates the button |
Not exposed (gaps the client must work around)
| Need | Status |
|---|---|
| List all active borrows (overdue sweep) | (planned) — findOverdue() exists unused (borrow-record.repository.ts:34-43); IMPLEMENTATION_PLAN.md:228 |
| Mark book damaged/lost, record lost | (planned) — enums exist (book.schema.ts:7-12) |
| Renew / reserve | (planned) — IMPLEMENTATION_PLAN.md:228 |
| Catalog import/export | (planned) — IMPLEMENTATION_PLAN.md:172,228 |
| Waive fine | (planned) — FineStatus.WAIVED exists (borrow-record.schema.ts:17) but nothing sets it |
| Book cover / attachments | (proposed) — files module exists (files.controller.ts:29-71), Book has no cover field (OQ-8) |
| QR/barcode scanning | (forward-looking) |
Loading / streaming / realtime
| Screen | Loading | Realtime |
|---|---|---|
| Catalog list | AppSkeleton | re-fetch on focus; WS (planned) |
| Book detail | skeleton | re-fetch on focus (issue/return elsewhere) |
| Borrowed-by-me | skeleton | re-fetch on focus |
| Issue/Return/Fine pay | button spinner | — |
Client-side error mapping (module)
| Screen | code | UI |
|---|---|---|
| issue | 409 (copies/cap) | banner + refresh availability |
| return | 409 (not active) | banner "Already returned" + refresh |
| create/update | 409 (ISBN) | inline field error |
| any | 404 | AppEmptyState / close form + snackbar |
| any | 400 | field errors |
| any | 401 → refresh → fail | sessionExpired |
| any | 429 | countdown, no retry |
| any | 5xx | generic + requestId, retry |
Optimistic / undo
- No optimistic mutations — issue/return/fine pay/create/update/delete are all server-confirmed (00-shared/07 §9).
- Undo: in-form edits only; delete = confirm dialog (soft delete server-side); no undo for issue/return (record lifecycle instead).
Notifications surface
BookIssued / BookReturned events (library.service.ts:141-148,180-191) are on the
domain event bus; notification/email consumers (proposed).
13 — State Management (Library Module)
- 1. CatalogCubit (S1 — list + search + filters)
- 2. BookDetailCubit (S2)
- 3. BookFormCubit (S3 — create + edit)
- 4. IssueCubit (S4)
- 5. BorrowsCubit (S5 — active + history)
- 6. ReturnCubit (S6)
- 7. OverdueCubit (S7)
- 8. FinePayCubit (inline)
- 9. Repository layer
- 10. Cross-cubit refresh rules
Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) backed by
LibraryRepository(dio) calling the endpoints in 12_API_Mapping.md. Module-wide rules: no optimistic mutations, server-confirmed writes only; availability and fine amounts are server-authoritative; overdue is client-derived (OQ-2).
1. CatalogCubit (S1 — list + search + filters)
stateDiagram-v2
[*] --> initial
initial --> loading : Load(page 1)
loading --> loaded : BookDoc[] + meta
loading --> error : 5xx / offline(no cache)
loaded --> loading : Refresh | Search(q) | LoadMore
loaded --> empty : 0 items
error --> loading : Retry
loaded --> loaded : Filter(chips) — in-memory only
- State:
{status, books[], meta, query, filters{category?, status?, availableOnly?}}. - Load:
GET /books?page&limit&q(library.controller.ts:33-41);meta.hasNextgatesLoadMore(library.service.ts:75). - Search: debounced
q→ server regex (title/author/ISBN,book.repository.ts:26-30). - Filters are client-side only (OQ-5): applied to
books[]before render, never sent as params. - Caching: key
lib:catalog:{tenant}:{q}:{page}, TTL 5 min, stale-while-revalidate (00-shared/06 §3.3); pull-to-refresh bypasses. - Events:
Load,Refresh,Search(q),LoadMore,ChangeFilters,Retry.
2. BookDetailCubit (S2)
- State:
{status, book?};book=BookDocmodel (id, title, author, isbn, publisher?, category?, edition?, totalCopies, availableCopies, status, shelfLocation?, description?). - Load:
GET /books/:id(library.controller.ts:43-47); 404 →notFoundterminal-ish state (empty + back). - Derived:
isAvailable = availableCopies > 0,canIssue = isAvailable && status == available. - Events:
Load(id),Refresh(focus),IssueDone/EditDone/DeleteDone(re-fetch or pop). - No cache (volatile) — re-fetch on focus (availability changes elsewhere).
3. BookFormCubit (S3 — create + edit)
- State:
{mode, form{title, author, isbn, publisher?, category?, edition?, totalCopies, shelfLocation?, description?}, copiesProjection, status: idle|submitting|error(field?)}. - Create submit →
POST /books(library.controller.ts:27-31); edit →PATCH /books/:id(:49-53). Success → emit done with server doc; navigate. - Copies projection: create
available = total(library.service.ts:41-46); editavailable = max(0, available + Δ)(:87-90). - Errors: 409 duplicate ISBN →
fieldErrors.isbn(server message verbatim); 400 → mapdetails[].message→ field; 5xx → error (form preserved). - No optimistic writes. Draft persist
(proposed).
4. IssueCubit (S4)
stateDiagram-v2
[*] --> idle
idle --> ready : student selected && dueDate valid
ready --> submitting : Confirm
submitting --> success(record) : 201
submitting --> noCopies : 409 (library.service.ts:109-111)
submitting --> capReached : 409 (library.service.ts:116-120)
submitting --> error : 5xx / network / 404
noCopies --> ready : refresh book (re-check)
capReached --> ready : refresh student borrows
error --> ready : Retry
- State:
{status, book, studentId?, dueDate?, notes?, errorKind?}. - Submit:
POST /books/issue {bookId, studentId, dueDate, notes?}(issue-book.dto.ts:4-21,library.controller.ts:61-65). noCopies/capReachedare banner states — re-read the book/borrows and return toready; never auto-resubmit.- Client guard: due date must be future (server has no min —
issue-book.dto.ts:14-15). - Double-tap guard:
submittingblocks re-entry (server has no idempotency — QA-2).
5. BorrowsCubit (S5 — active + history)
- State:
{status, active[], history[], studentId}. - Load:
GET /books/borrows/:studentId/active(library.controller.ts:79-83) andGET /books/borrows/:studentId(:73-77) — not paginated (library.service.ts:195-201); client shows first N + "load more"(proposed). bookIdis populated (borrow-record.repository.ts:31,42);studentIdidentity comes from Students profile state (OQ-9).- Derived per record:
isOverdue = status=='active' && now > dueDate,daysOverdue = ceil((now − dueDate)/day),finePreview = daysOverdue × 5(mirrorslibrary.service.ts:211-217; labeled "estimate"). - Events:
Load(studentId),Refresh,ReturnedDone(recordId)(replace doc),FinePaidDone(recordId)(replace doc). - Caching: active 1 min TTL; history 5 min; focus refresh bypasses.
6. ReturnCubit (S6)
- State:
{status, record?, fineAmount?, fineStatus?, notes?, derived{daysOverdue, computedFine}}. - Prefill:
fineAmount = computedFine(client mirror of server rule); staff may edit (@Min(0)—return-book.dto.ts:9-13). - Submit →
POST /books/return {borrowRecordId, fineAmount?, notes?}(library.controller.ts:67-71) → server doc (server recomputes fine when no override —library.service.ts:159). notActivestate on 409 (library.service.ts:155-157) → banner + refresh record.- If returned
fineAmount > 0→ offer pay:POST /books/fines/:id/pay(library.controller.ts:85-89) →fineStatus: PAID(library.service.ts:203-209).
7. OverdueCubit (S7)
- State:
{status, rows[]}where row ={student, book, dueDate, daysLate, finePreview}. - Derived: aggregates active borrows from
BorrowsCubit-loaded data (per-student); no server endpoint (OQ-2). Persistent UI notice: "derived — server scan planned". - Refresh re-pulls the underlying active lists.
- Server-side sweep
(planned)(IMPLEMENTATION_PLAN.md:228) will replace this.
8. FinePayCubit (inline)
{status, recordId?, fineAmount?};Pay→ confirm dialog → endpoint → PAID doc.- Server doesn't guard state (OQ-6): cubit only exposes pay when
fineStatus == 'pending'andfineAmount > 0.
9. Repository layer
LibraryRepository (dio): methods map 1:1 to the endpoints in 12_API_Mapping.md;
wraps the envelope, unwraps data/meta, throws typed ApiException{code,message,details,requestId};
injects auth token + x-request-id. Models (Book, BorrowRecord) parse the raw
docs; enums kept as string constants mirroring book.schema.ts:7-12,
borrow-record.schema.ts:7-18.
10. Cross-cubit refresh rules
| Write done | Refresh |
|---|---|
| Book created/updated/deleted | CatalogCubit + BookDetailCubit |
| Issue succeeded | BookDetailCubit (availability) + BorrowsCubit (student) |
| Return succeeded | BookDetailCubit + BorrowsCubit + OverdueCubit |
| Fine paid | BorrowsCubit (record chip) |
All refreshes are server-confirmed re-fetches; no local doc mutation except replacing with the server response.
14 — QA Checklist (Library Module)
- QA-1. Availability race (concurrent issue of the last copy) — CRITICAL
- QA-2. Double-issue (same book, same student, twice)
- QA-3. Copy-count integrity
- QA-4. Fines — computation and override
- QA-5. Overdue computation (client-derived)
- QA-6. Business-rule 409s (message accuracy)
- QA-7. Envelope, auth, tenant isolation
- QA-8. Search & pagination
- QA-9. Client states (offline, rate, permissions)
- QA-10. Events & side effects
- Severity summary
Test matrix for the client + API surface. Server behaviors cited from
library.service.ts/book.schema.ts/borrow-record.schema.ts; E2E requires MongoDB + Redis (see AGENTS.md). Focus areas per the module brief: availability race, double-issue, overdue computation, fines.
QA-1. Availability race (concurrent issue of the last copy) — CRITICAL
Server behavior today: issueBook reads book.availableCopies then writes
availableCopies - 1 (library.service.ts:108-130) — a read-then-write without
atomic conditional update. Two concurrent issues of the last copy can both pass the
< 1 check (OQ-7).
| Check | Expected |
|---|---|
Two parallel POST /books/issue for the same book with availableCopies = 1 | at least one must fail (or both succeed but availability stays ≥ 0 — today this is a race; assert no negative availableCopies; document as known gap) |
Issue when availableCopies = 0 (sequential) | 409 "No copies available for borrowing." (library.service.ts:109-111) |
| Client on 409 | refreshes availability, never auto-retries (13 §4) |
| Client button during submit | disabled/spinner — prevents client-side double-tap (server has no idempotency, QA-2) |
availableCopies never negative after any sequence | property test over issue/return mixes |
QA-2. Double-issue (same book, same student, twice)
| Check | Expected |
|---|---|
Issue same (bookId, studentId) while a record is ACTIVE | server allows it today (no active-record check per book+student; only the 5-cap library.service.ts:116-120) — assert & document as gap (planned) |
| Two quick submits of the same issue form | client blocks (submit lock); if both reach server, cap may still allow → duplicates possible; log for review |
| Book status after double-issue | status flips to borrowed when available hits 0 (library.service.ts:125-129) |
QA-3. Copy-count integrity
| Check | Expected |
|---|---|
totalCopies edit by +N | availableCopies +N, floor 0 (library.service.ts:87-90) |
Edit totalCopies below currently available | available clamps at 0, never negative |
Create with totalCopies = 5 | available = 5 (library.service.ts:41-46); default 1 |
| Delete book with ACTIVE records | 409 (library.service.ts:97-102); delete succeeds only when no active records; soft-delete (book gone from lists, not physically deleted) |
QA-4. Fines — computation and override
| Check | Expected |
|---|---|
| Return on due date | fineAmount = 0, fineStatus unset (library.service.ts:159-169) |
| Return 1 ms after due date | ceil(diff/24h) × 5 = 5 for < 24 h late — ceil makes any late return ≥ 1 day (library.service.ts:211-217) |
| Return 3.5 days late | ceil(3.5) × 5 = 20 |
Return with fineAmount: 0 | override wins (0, library.service.ts:159) — "waive via zero" path |
Return with fineAmount: 999 | override wins — server trusts client; QA must confirm UI hides override for non-staff (OQ-6) |
Fine on ACTIVE record: POST /books/fines/:id/pay | succeeds today (no guard) — assert UI gates by fineStatus == pending |
| Pay twice | idempotent overwrite to PAID (no error) — no double-charge UI implication (units only) |
fineStatus: WAIVED | never produced by any endpoint — don't test as reachable |
QA-5. Overdue computation (client-derived)
| Check | Expected |
|---|---|
OverdueTag shows iff status=='active' && now > dueDate | derived rule (13 §5); server never writes OVERDUE (OQ-2) |
| Days late / fine preview | ceil((now−dueDate)/day) × 5 matches server rule when the same clock is used; label "estimate" |
| Clock skew (device vs server) | preview differs from server fine at return → UI must show server value after return (authoritative) |
Server findOverdue() | exists unused (borrow-record.repository.ts:34-43) — plan tests when the endpoint ships (planned) |
QA-6. Business-rule 409s (message accuracy)
| Check | Expected |
|---|---|
| Duplicate ISBN | 409, exact message Book with ISBN "…" already exists. (library.service.ts:37-39) |
| No copies | 409 exact copy (:109-111) |
| 5-active cap | 409 exact copy (:116-120) — boundary: exactly 5 active → block; 4 → allow |
| Return non-ACTIVE record | 409 "Book was not actively borrowed." (:155-157) |
| Unknown book/record | 404 (:80,153-154,207) |
| Invalid Mongo id | 400 VALIDATION_ERROR |
QA-7. Envelope, auth, tenant isolation
| Check | Expected |
|---|---|
Every response wraps {success, message, data, meta?, timestamp, requestId} | response-envelope.interceptor.ts:44-52 |
x-request-id echoed | requestId in body (http-exception.filter.ts:45) |
| No token → 401 | JwtAuthGuard (library.controller.ts:22) |
| Tenant A book invisible to tenant B | all repos tenant-scoped; unique ISBN is per-tenant (book.schema.ts:51) |
| ISBN duplicate across tenants | allowed (both succeed) |
QA-8. Search & pagination
| Check | Expected |
|---|---|
q matches title/author/ISBN, case-insensitive substring | regex $options: 'i' (book.repository.ts:26-30) |
No q → all books sorted title asc | library.service.ts:68-74 |
page/limit math | meta matches buildPaginationMeta (library.service.ts:75); last page hasNext = false |
Non-numeric page/limit | Number() cast → NaN edge (400 not guaranteed — flag) |
QA-9. Client states (offline, rate, permissions)
| Check | Expected |
|---|---|
| Offline with cache | cached catalog/borrows render + AppOfflineBanner; writes blocked |
| 429 | countdown banner, no auto-retry |
| 401 mid-session | token refresh then retry once; fail → sessionExpired |
RBAC (planned) | with perms enforced: no books.create → FAB hidden; no fines.pay → pay hidden (matrix 02 §6) |
QA-10. Events & side effects
| Check | Expected |
|---|---|
Issue emits BookIssued {bookId, studentId} | event bus (library.service.ts:141-148); correlationId/tenantId/actorId populated |
Return emits BookReturned incl. fineAmount | (:180-191) |
issuedBy on the record | never written (:132-139) — assert field absent in responses; test when fixed (OQ-3) |
Severity summary
| # | Area | Severity | Status |
|---|---|---|---|
| QA-1 | availability race | High | server gap (OQ-7); client: refresh-on-409 |
| QA-2 | double-issue same book+student | Medium | server gap (planned); client: submit lock |
| QA-4 | fine override trusted | Medium | client: staff-only UI |
| QA-5 | overdue derived | Low | server scan (planned) |
| QA-6 | 409 messages | High | test verbatim |
15 — Flutter Implementation Guide (Library Module)
- 1. Module layout (feature-first)
- 2. Models (parse raw docs; never derive server truth)
- 3. Repository (one class, typed errors)
- 4. Cubits
- 5. Caching
- 6. Error UX mapping
- 7. Permissions-aware UI (RBAC planned — OQ-1)
- 8. Date & fine handling
- 9. Testing
- 10. Known ceilings (ponytail notes)
Concrete build guide for the library client on top of the app architecture in 00-shared/11. Follows the shared repo/conventions; module specifics only.
1. Module layout (feature-first)
lib/
features/library/
data/
models/book.dart # BookDoc → Book
models/borrow_record.dart # BorrowRecordDoc → BorrowRecord
library_repository.dart # dio → endpoints (12_API_Mapping.md)
library_local_cache.dart # catalog/borrows TTL caches
domain/
fine_calculator.dart # ceil((now-due)/day) * 5 (mirror library.service.ts:211-217)
presentation/
catalog/ (catalog_cubit.dart, catalog_screen.dart, book_row.dart)
book_detail/ (book_detail_cubit.dart, book_detail_screen.dart)
book_form/ (book_form_cubit.dart, book_form_screen.dart)
issue/ (issue_cubit.dart, issue_sheet.dart)
my_borrows/ (borrows_cubit.dart, my_borrows_screen.dart, borrow_record_card.dart)
return_/ (return_cubit.dart, return_sheet.dart)
overdue/ (overdue_cubit.dart, overdue_screen.dart)
widgets/ (availability_badge.dart, fine_chip.dart, overdue_tag.dart,
copy_stepper.dart, fine_summary_panel.dart)
2. Models (parse raw docs; never derive server truth)
enum BookStatus { available, borrowed, damaged, lost } // book.schema.ts:7-12
enum BorrowStatus { active, returned, overdue, lost } // borrow-record.schema.ts:7-12
enum FineStatus { pending, paid, waived } // :14-18
class Book { final String id, title, author, isbn; final String? publisher,
category, edition, shelfLocation, description; final int totalCopies,
availableCopies; final BookStatus status; }
bool get isAvailable => availableCopies > 0;
class BorrowRecord { final String id, bookId, studentId; final String? issuedBy;
final DateTime borrowedAt, dueDate; final DateTime? returnedAt;
final BorrowStatus status; final double fineAmount; final FineStatus? fineStatus;
final String? notes; final Book? book; } // bookId populated: borrow-record.repository.ts:31,42
bool get isOverdue => status == BorrowStatus.active && DateTime.now().isAfter(dueDate);
- Keep enums as strings matching server values — no renaming.
fineAmountisdouble/num(plain units; no currency —library.service.ts:25).
3. Repository (one class, typed errors)
class LibraryRepository {
Future<Paged<Book>> searchBooks({int page = 1, int limit = 20, String? q});
Future<Book> getBook(String id);
Future<Book> createBook(CreateBookDto dto);
Future<Book> updateBook(String id, UpdateBookDto dto);
Future<void> deleteBook(String id);
Future<BorrowRecord> issueBook({required String bookId, required String studentId,
required DateTime dueDate, String? notes});
Future<BorrowRecord> returnBook({required String borrowRecordId,
double? fineAmount, String? notes});
Future<List<BorrowRecord>> borrowHistory(String studentId); // controller.ts:73-77
Future<List<BorrowRecord>> activeBorrows(String studentId); // controller.ts:79-83
Future<BorrowRecord> payFine(String borrowRecordId); // controller.ts:85-89
}
- Unwrap
data/metafrom the envelope (response-envelope.interceptor.ts:44-52); throwApiException(code, message, details, requestId)onsuccess:false. - ISO dates: parse
dueDate/borrowedAt/returnedAtwith UTC-safe parsing; senddueDate.toIso8601String()(@IsDateString,issue-book.dto.ts:14-15). - No request body ever includes
tenantId(JWT-scoped).
4. Cubits
Implement the seven Cubits from 13_State_Management.md:
CatalogCubit, BookDetailCubit, BookFormCubit, IssueCubit, BorrowsCubit,
ReturnCubit, OverdueCubit (+ inline FinePayCubit). Rules:
- No optimistic writes — every mutation emits server-confirmed docs.
- 409 = refresh, never retry;
noCopies/capReachedare named states. - Double-tap guard — submit states are final until the response returns.
- Overdue/fine-preview derived via
FineCalculator(unit-testable pure function). - Cross-cubit refresh table (
13 §10): issue → refresh detail + borrows; return → detail + borrows + overdue; pay → borrows.
5. Caching
| Data | Key | TTL | Notes |
|---|---|---|---|
| Catalog page | lib:catalog:{tenant}:{q}:{page} | 5 min | stale-while-revalidate; pull-to-refresh bypasses |
| Book detail | lib:book:{id} | 1 min | focus re-fetch |
| Active borrows | lib:active:{studentId} | 1 min | focus re-fetch |
| History | lib:history:{studentId} | 5 min | focus re-fetch |
| Writes | — | never cached |
6. Error UX mapping
| Code | Handling |
|---|---|
400 VALIDATION_ERROR | map details[].message → field errors (forms) |
409 DUPLICATE_RESOURCE | context banners: ISBN dup (inline), no copies, 5-cap, not-active |
| 404 | empty states / close form + snackbar |
| 401 | refresh token once → retry → sessionExpired |
| 429 | countdown banner, no retry |
| 5xx / network | generic + requestId + retry action |
7. Permissions-aware UI (RBAC planned — OQ-1)
Gate from the user's role permissions (matrix in 02 §6):
books.read (module visible), books.create (FAB), books.update (edit),
books.delete (menu), books.issue (issue CTA), books.return (return button),
fines.pay (pay action). Until the server enforces, the client hides what the role
lacks; when guards land, keep the same gates.
8. Date & fine handling
- Due dates: native date picker; client min = tomorrow; default +14 days.
FineCalculator.daysOverdue(dueDate) = ceil((now − dueDate).inDays fractional)— must mirrorMath.ceil(diffMs / dayMs)(library.service.ts:215).- Fine preview is always labeled estimate; after return, render the server's
fineAmountverbatim. - Timezone: compare dates in the tenant's local day boundary
(proposed); today use UTC instants to stay consistent with the server'snew Date()comparisons.
9. Testing
- Unit:
FineCalculator(due-today → 0; +1 ms → 5; +3.5 d → 20), model parsing, enum mapping,BookRowbadge states. - Cubit: issue 409 paths (
noCopies,capReached), returnnotActive, fine pay gating, catalog filter logic. - Widget: sheet non-dismissible during submit; FAB hidden without permission.
- Integration: repository against the API with mocked envelope + error bodies.
- E2E (needs Mongo + Redis): the scenarios in
14_QA_Checklist.mdQA-1/2/4/6.
10. Known ceilings (ponytail notes)
- Overdue screen is derived data; replace with the server endpoint when the scan
ships
(planned)(IMPLEMENTATION_PLAN.md:228). - No QR/barcode scan yet —
(forward-looking); keep the issue flow keyed onbookIdso a scanner can later inject the same id. - Analytics
(proposed)— hook names from05 §Analyticswhen the SDK lands.
01 — Product Overview (Notifications Module)
- 1. Product Statement
- 2. Scope — In (v1, implemented)
- 3. Scope — Out (planned / forward-looking)
- 4. PRD Exclusion: Native Mobile App
- 5. How Notifications Are Created
- 6. Non-Goals (v1)
Single notification engine across all channels. Scope for this design pass: the in-app notification inbox as implemented in
src/modules/notifications/. Multi-channel dispatch (email, push, WhatsApp, WebSocket) is planned — seestudylyon-blueprint/04-Modules/Notifications.md:11-17.
1. Product Statement
StudyLyon users (students, teachers, parents, staff, org admins) receive a
persistent, per-tenant in-app notification inbox generated from system events
(account lifecycle today; homework/attendance/exams in the event pipeline). Each
notification is a small record: type, title, body, optional data payload,
and a readAt flag (notification.schema.ts:15-32). The inbox supports
list + pagination, single & bulk mark-read, and an unread-count badge.
2. Scope — In (v1, implemented)
| Area | Status |
|---|---|
| Persisted in-app notifications, tenant-scoped | ✅ src/modules/notifications/ |
Event-driven creation (EventBus fan-in) | ✅ notifications.handler.ts |
List w/ pagination + unreadOnly filter | ✅ GET /api/v1/notifications |
| Unread count | ✅ GET /api/v1/notifications/unread-count |
| Mark one read / mark all read | ✅ PATCH /api/v1/notifications/:id/read, PATCH /api/v1/notifications/read-all |
3. Scope — Out (planned / forward-looking)
| Area | Where |
|---|---|
| Email / Push / WhatsApp / WebSocket dispatch | Notifications.md:13; QUEUE.EMAILS / PUSH / WHATSAPP exist (queue.constants.ts:2-4) but no workers consume them for notifications |
POST /api/v1/notifications/send (internal send) | Notifications.md:24 — (planned) |
| Notification templates (i18n + interpolation) | Notifications.md:7 — (planned) |
| Per-user channel preferences | Notifications.md:7,27-28 — (planned) |
| Push/FCM device tokens | POST /api/v1/notifications/device-token (future) — docs/user-flows/END_TO_END_USER_FLOWS.md:788 — (forward-looking) |
| Push for coaching test reminders / DPP alerts / batch start | docs/IMPLEMENTATION_PLAN.md:769,859 — (forward-looking) |
Analytics events {module}.{screen}.{action} | (proposed) — SDK open, see 00-shared/10 §8 |
4. PRD Exclusion: Native Mobile App
Per docs/IMPLEMENTATION_PLAN.md:859 the mobile app is an open clarifying
question, not a v1 deliverable. The PRD scopes the web app + API first; this
design therefore covers the web/app-agnostic in-app inbox and keeps mobile
push behind the (forward-looking) markers above. Any native-app-specific screen
is out of scope.
5. How Notifications Are Created
- Any module emits a
DomainEventon the sharedEventBus. NotificationsHandlersubscribes viaeventBus.onAny(notifications.handler.ts:61) and maps event → notification config (handler.ts:8-31,33-50).- Recipient resolves from
payload.userId ?? payload.recipientId ?? actorId(handler.ts:76-79). - Persist happens inside a synthetic tenant context (
handler.ts:81-99). - Client reads via the four authenticated endpoints.
Today only four event types produce notifications (see 03_User_Journey and 12_API_Mapping for the list).
6. Non-Goals (v1)
- No read-receipt analytics or per-recipient delivery stats.
- No notification detail endpoint (
GET /:id) — detail is derived client-side from the list item +datapayload. - No server-side push to external providers.
- No admin-facing send UI (see 08_Form_Specifications).
02 — User Personas (Notifications Module)
- P1 — Student (School & Coaching)
- P2 — Teacher
- P3 — Parent
- P4 — Org Admin / School Admin
- P5 — Platform Admin (Super Tenant)
- Permission Matrix
Derived from source only. Personas are the five identity classes present in StudyLyon (
permissions.constants.ts+ RBAC member model). Notification content today is account-lifecycle only; domain payloads (homework, results, fees) arrive via the event pipeline — see gaps in 14_QA_Checklist and 01_Product_Overview §3.
P1 — Student (School & Coaching)
- Profile: enrolled pupil; coaching variant tracked per
docs/IMPLEMENTATION_PLAN.md:769(test reminders, DPP due alerts, batch start, rank updates — (forward-looking)). - Today receives: welcome (
UserRegistered), email-verified, verification resent, password reset —notifications.handler.ts:8-50. - Needs: glanceable unread badge, newest-first list, one-tap mark read.
- Pain points: buried system emails; notification fatigue if every school event pings the inbox (see 09_User_Behaviour).
P2 — Teacher
- Profile: staff member with teaching duties; homework-graded / attendance
events route to the
in-appqueue perevent-queue-map.ts:14-25(planned). - Needs: distinguish actionable items (grade homework, results published) from informational ones; bulk "mark all read" after a teaching block.
- Today: same four account events as P1.
P3 — Parent
- Profile: account linked to a student; reads announcements and fee invoices.
Invoice events exist (
event-queue-map.ts:41-42→emailsqueue). - Needs: unread count on the dashboard shell; parent is a viewer — no
send capabilities (
notification.updateis not in parent's default set).
P4 — Org Admin / School Admin
- Profile: tenant administrator; manages users, fees, announcements.
- Needs: assurance that system events surfaced to the right recipients;
(planned) manual send to a target audience via
POST /api/v1/notifications/send(Notifications.md:24) and channel-preferences management (Notifications.md:27-28). - Permissions:
notification.read,notification.update(permissions.constants.ts:32-33).
P5 — Platform Admin (Super Tenant)
- Profile: StudyLyon operator; cross-tenant oversight.
- Needs: DLQ/retry visibility for the notification pipeline
(
Notifications.md:41—NotificationFailedevent, (planned)).
Permission Matrix
| Action | Student | Teacher | Parent | Org Admin |
|---|---|---|---|---|
| List my notifications | ✅ | ✅ | ✅ | ✅ |
| Mark read (own only) | ✅ | ✅ | ✅ | ✅ |
| Mark all read (own only) | ✅ | ✅ | ✅ | ✅ |
| Unread count | ✅ | ✅ | ✅ | ✅ |
| Send (internal) | — | — | — | (planned) |
All read actions are owner-scoped by the service: markAsRead returns null
when recipientId !== userId (notifications.service.ts:54-59).
03 — User Journeys (Notifications Module)
- J1 — New User Onboarding (Welcome + Verification)
- J2 — Resend Verification Email
- J3 — Password Reset Completed
- J4 — Unread Inbox Visit (Daily Loop)
- J5 — Empty State
- Planned Journeys (not implemented)
All journeys are grounded in the implemented flow: event →
EventBus→NotificationsHandler→ persistence → authenticated API (notifications.handler.ts:61-99).
J1 — New User Onboarding (Welcome + Verification)
UserRegisteredfires on signup.- Handler maps to
WELCOME(notifications.handler.ts:41-49), body interpolatesfirstNamefrompayload(handler.ts:44-47), recipient =payload.userId. - User opens the app → bell badge shows 1 unread → inbox lists "Welcome to StudyLyon".
- User verifies email →
EmailVerified→ badge 2 ("Email Verified",handler.ts:16-20).
Exit states: badge increments, list stays newest-first, tapping marks read.
J2 — Resend Verification Email
- User requests resend →
EmailVerificationResent(handler.ts:21-25). - Notification "Verification Email Sent" is created; user knows an email is coming without leaving the app.
J3 — Password Reset Completed
- Reset flow finishes →
PasswordResetCompleted→ "Password Reset" (handler.ts:26-30). - Notification acts as confirmation + implicit security signal (someone reset it).
J4 — Unread Inbox Visit (Daily Loop)
- Badge shows
nunread (GET /api/v1/notifications/unread-count). - User opens
/notifications→GET /api/v1/notifications?page=1&limit=20→{ data, meta }(notifications.service.ts:39-52). - Tap item →
PATCH /api/v1/notifications/:id/read→ badge decrements. - Scroll → infinite pagination (
meta.hasNext→ next page,pagination-query.dto.ts:32-39). - Optional "Mark all read" →
PATCH /api/v1/notifications/read-all→ badge = 0.
J5 — Empty State
First-run user (or after mark-all): list renders empty state. No notifications are ever auto-deleted; an empty list is a state, not a flow — see 05_Screen_Inventory §3.
Planned Journeys (not implemented)
- J6 — Domain fan-out: homework created/submitted/graded, results published,
student/teacher/staff/parent created — routed to the
in-appqueue inevent-queue-map.ts:10,13,22-40but no worker/handler consumes them — (planned), see 14_QA_Checklist §G. - J7 — Push on mobile: test reminder push (coaching) —
docs/IMPLEMENTATION_PLAN.md:859— (forward-looking); device-token registration(future)—docs/user-flows/END_TO_END_USER_FLOWS.md:788. - J8 — Announcements in inbox:
AnnouncementPublishedis emitted (announcement.service.ts:80-91) but not mapped inEVENT_TYPE_MAP— announcements keep their own read receipts (announcement.service.ts:95-102). No overlap today; decide in a later phase whether announcements fan into the inbox (see 14_QA_Checklist §E).
04 — Information Architecture (Notifications Module)
- 1. Entity Model (source of truth:
notification.schema.ts) - 2. Planned Entities (blueprint, not implemented)
- 3. Type Taxonomy (current)
- 4. App Navigation Placement
- 5. Data Flow
Global IA baseline in 00-shared/05; this file adds the module slice.
1. Entity Model (source of truth: notification.schema.ts)
Notification (collection: "notifications", timestamps)
├── tenantId (from BaseSchema — every doc tenant-scoped, `base.schema.ts`)
├── recipientId ObjectId, indexed — the owning user (`notification.schema.ts:16-17`)
├── type enum: email_verified | password_reset | verification_resent | welcome
│ (`notification.schema.ts:7-12`)
├── title string, required — user-visible headline
├── body string, required — user-visible copy
├── data Record<string, unknown> — raw domain-event payload (`schema:28-29`)
├── readAt Date | null — null = unread (`schema:31-32`)
└── BaseSchema createdAt / updatedAt / version / deletedAt (soft delete)
Indexes (notification.schema.ts:37-38):
{ tenantId, recipientId, readAt }— unread-count queries.{ tenantId, recipientId, createdAt: -1 }— inbox ordering.
2. Planned Entities (blueprint, not implemented)
| Entity | Collection | Source |
|---|---|---|
| NotificationTemplate | notification_templates | Notifications.md:7 |
| NotificationPreference | notification_preferences | Notifications.md:7 |
3. Type Taxonomy (current)
NotificationType | Source Event | Title (title-cased constant) |
|---|---|---|
email_verified | EmailVerified | Email Verified — handler.ts:16-20 |
verification_resent | EmailVerificationResent | Verification Email Sent — handler.ts:21-25 |
password_reset | PasswordResetCompleted | Password Reset — handler.ts:26-30 |
welcome | UserRegistered | Welcome to StudyLyon — handler.ts:41-49 |
Types are a closed enum on the server; the client renders by type and must
treat unknown enum values as generic "system notification".
4. App Navigation Placement
- Bell/badge icon in the app shell (see
05_Screen_Inventory §2). - Inbox route
/notifications— top-level destination, reachable from the shell icon and from deep links. - Detail — no server route; derived from list item +
data(see06). - Preferences (planned) —
GET/PATCH /api/v1/notifications/preferences(Notifications.md:27-28) will live behind the inbox, (planned).
5. Data Flow
Domain module ──emit──> EventBus (onAny)
└──> NotificationsHandler.handle() (handler.ts:61-99)
└──> recipientId = payload.userId ?? recipientId ?? actorId (handler.ts:76-79)
└──> NotificationsService.create() (service.ts:35-37, internal only)
└──> NotificationsRepository.create() (tenant-scoped, BaseRepository)
└──> Mongo "notifications"
Client <── authenticated API (controller.ts:21-47)
There is no HTTP write path — creation is event-only; NotificationsService
is exported from the module (notifications.module.ts:28) for in-process reuse.
05 — Screen Inventory (Notifications Module)
- Legend
- 1. Notification Inbox Screen (
/notifications) - 2. Unread Badge (Shell Widget — not a screen)
- 3. Empty State (state of §1, not a separate route)
- 4. Notification Detail (
/notifications/:id)(planned) - 5. Notification Preferences
(planned) - 6. Admin Manual Send
(planned) - Permission Map
Every screen of the Notifications module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics.
Legend
States = idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission.
Analytics events follow {module}.{screen}.{action} (proposed) — 00-shared/10 §8.
1. Notification Inbox Screen (/notifications)
| Field | Detail |
|---|---|
| Purpose | Newest-first list of my persisted notifications w/ pagination |
| Entry | shell bell icon, deep link /notifications, badge tap |
| Exit | tap item → mark-read + optional route via data payload; back to shell |
| Data | GET /api/v1/notifications?page=1&limit=20[&unreadOnly=true] → { data, meta } (notifications.controller.ts:21-29) |
| Composition | AppBar ("Notifications" + Mark-all action), lazy list of NotificationListTile, pagination footer, empty state |
| Primary CTA | Mark all read (PATCH /api/v1/notifications/read-all) — only when unread > 0 |
| States | idle, loading (first page skeleton), pagination loading (footer), empty (J5), error offline w/ retry, error 4xx/5xx |
| Analytics | notifications.list.open, notifications.list.loaded, notifications.item.tap, notifications.mark_all |
| Adaptive | phone single pane; tablet/desktop max-width ~640 dp centered list |
| a11y | unread items announced as "unread", live-region on badge change |
| Motion | list m-base fade-in; read swipe m-fast (see 00-shared/08) |
2. Unread Badge (Shell Widget — not a screen)
| Field | Detail |
|---|---|
| Purpose | Persistent unread count on the app shell |
| Data | GET /api/v1/notifications/unread-count → { count } (controller.ts:31-35) |
| States | 0 (hidden), n ≤ 99 (numeric), > 99 ("99+"), error (hidden, silent) |
| Refresh | on app foreground, on inbox mutations, after mark-read responses |
| a11y | semantics label "N unread notifications" |
3. Empty State (state of §1, not a separate route)
| Field | Detail |
|---|---|
| Trigger | meta.totalItems === 0, or unreadOnly=true with zero unread |
| Copy | "No notifications" + contextual subline (unread filter → "You're all caught up") |
| CTA | none (v1); (proposed) "Clear filter" when unreadOnly active |
| a11y | no live-region spam; static illustration is decorative (00-shared/09) |
4. Notification Detail (/notifications/:id) (planned)
| Field | Detail |
|---|---|
| Purpose | Read full body + act on data payload (deep-link target) |
| Entry | tap item whose data carries a route hint; today no link field exists in the model (notification.schema.ts:28-29 — free-form data only) |
| Data | no GET /:id endpoint — must pass the full item client-side (see 01 §6) |
| Deep link | data payload carries the source event fields (e.g. userId); a targetUrl/entityId convention is (proposed) for v2 |
| Note | The API gap makes this screen client-derived; mark route dormant until a targetUrl convention lands |
5. Notification Preferences (planned)
| Field | Detail |
|---|---|
| Purpose | Per-user channel prefs (email/push/in-app) |
| Data | GET /api/v1/notifications/preferences, PATCH /api/v1/notifications/preferences (Notifications.md:27-28) |
| Status | Not implemented — keep route dormant |
6. Admin Manual Send (planned)
| Field | Detail |
|---|---|
| Purpose | Org admin pushes a notification to a target audience |
| Data | POST /api/v1/notifications/send (internal/service) (Notifications.md:24) |
| Permissions | requires notification.send — absent from ALL_PERMISSIONS (permissions.constants.ts) — (planned) |
| Status | Not implemented — see 08_Form_Specifications |
Permission Map
All five implemented surfaces are user-scoped reads/writes of own data;
notification.read + notification.update (permissions.constants.ts:32-33) are
declared, and every controller endpoint is @UseGuards(JwtAuthGuard)
(notifications.controller.ts:16).
06 — Screen Specifications (Notifications Module)
Full interaction spec for the Notification Inbox screen — the only fully implemented screen (plus the shell badge). Shared primitives (text fields, motion tokens, a11y baseline) live in 00-shared/02,03,08,09; referenced by name.
1. Notification Inbox (/notifications)
1.1 Layout (phone)
┌──────────────────────────────┐
│ AppBar: Notifications [A] │ [A] = Mark-all-read TextButton
├──────────────────────────────┤
│ NotificationListTile (unread)│ leading avatar/icon, title (bold),
│ │ body (2-line ellipsis), timestamp,
│ │ unread dot (right)
│ NotificationListTile (read) │ title regular weight, dot removed
│ … │
│ [loading footer / retry] │
└──────────────────────────────┘
- Max content width 640 dp centered (tablet/desktop).
- No separate detail page in v1 — tapping an item marks it read; if
datacontains a known route hint ((proposed)data.targetUrl), navigate.datais the raw event payload (notification.schema.ts:28-29), so v1 never guesses — unknown payloads only mark read.
1.2 List Item (NotificationListTile)
| Region | Content | Rules |
|---|---|---|
| Leading | type icon (see 11_Design_System_Mapping §3) | 40 dp, tinted surface |
| Title | title (server, required) | unread → bold; 1 line |
| Body | body (server, required) | 2-line clamp, textSecondary |
| Trailing | timestamp + unread dot | relative time ("2m", "3h", "5d" — 00-shared/02); dot = readAt == null |
| Tap | mark read + optional navigate | full-width hit target ≥ 48 dp |
1.3 AppBar Actions
- Mark all read — visible only when unread > 0; disabled while a request is
in flight. Calls
PATCH /api/v1/notifications/read-all(controller.ts:43-47). - Confirm dialog? No — action is reversible-cost-free; show a transient confirmation ("All notifications marked as read") instead.
1.4 Data & Pagination
- First load:
GET /api/v1/notifications?page=1&limit=20. - Response envelope:
{ data: Notification[], meta: PaginationMeta }wheremeta = { page, limit, totalItems, totalPages, hasNext, hasPrevious }(pagination-query.dto.ts:32-39). - Infinite scroll: fetch
page+1when last item enters viewport andmeta.hasNext; append. Footer shows loading spinner; on failure, footer becomes a retry row (do not clear existing items). - Pull-to-refresh: refetch page 1 and replace the list (keep scroll position if totalItems unchanged).
1.5 Unread Filter (Filter Chip — optional v1.1)
unreadOnly=true(list-notifications.dto.ts:21-25, boolean-coerced from'true'). Toggling refetches from page 1.- Empty-with-filter state: "You're all caught up" + "Clear filter" (proposed).
1.6 States
| State | UI | Notes |
|---|---|---|
| idle (loaded) | list | items sorted createdAt desc — server contract (repository.ts:66) |
| first-load loading | skeleton rows (4×) | never spinner alone |
| pagination loading | footer spinner | existing rows untouched |
| empty | illustration + "No notifications" | see 05 §3 |
| offline (first load) | error panel + Retry | native connectivity check; cached copy if available |
| offline (pagination) | footer "Couldn't load more — Retry" | — |
| 4xx/5xx | error panel + Retry | 401/403 → shell handles auth redirect |
| disabled | n/a — no permission-gated controls on own inbox | — |
1.7 a11y
- Unread items:
semanticsannounce "unread" suffix; dot is not the only indicator (title weight differs too —00-shared/09). - Badge:
label = "N unread notifications"; updates via live region, not focus. - Mark-all: full label "Mark all notifications as read".
1.8 Motion
- First-load list items: staggered fade
m-base. - Read transition: dot removal + weight change
m-fast; optional swipe-to-mark-read (Dismissible,m-fast) — (proposed). - Badge count change:
m-fastscale bounce; see00-shared/08.
1.9 Analytics (proposed)
notifications.list.open | loaded(n,unread) | item.tap(id,type) | item.mark_read | mark_all | filter.set(unreadOnly) | paginate(page) | error(code)
2. Unread Badge (shell)
- Poll vs push: no WebSocket consumer exists in this module (
01 §3) — v1 refreshes on foreground + after inbox mutations; (forward-looking) realtime via WebSocket/ws(END_TO_END_USER_FLOWS.md:773-775). - Count ceiling: display
99+above 99. - Hide when 0; no badge flash on first open of the session.
3. System Feedback (transient)
| Trigger | Feedback |
|---|---|
| Mark-all success | SnackBar "All notifications marked as read" |
| Mark-read failure | silent retry ×1, then snackbar; item stays unread |
| Unread-count fetch failure | badge hidden silently (never show "-1") |
07 — Component Library (Notifications Module)
- 1. Component Inventory
- 2. NotificationListTile Contract
- 3. NotificationBadge Contract
- 4. Reuse Notes
Authoritative shared components in 00-shared/03; this file lists the module's component set and which shared primitives each is composed of. All items are derived from the API contract in 12_API_Mapping.
1. Component Inventory
| # | Component | Purpose | Composed of (from 00-shared/03) | State |
|---|---|---|---|---|
| C1 | NotificationListTile | One inbox row | Card/ListTile, Avatar(icon), Text (title/body), relative-time Text, UnreadDot | ✅ |
| C2 | UnreadDot | 8 dp accent dot, unread only | custom Paint (no shared dep) | ✅ |
| C3 | NotificationBadge | Shell bell badge w/ count (0/n/99+) | Badge widget, IconButton(bell) | ✅ |
| C4 | InboxEmptyState | Empty/zero-unread illustration + copy | EmptyState (shared) | ✅ |
| C5 | MarkAllReadAction | AppBar text button, conditional visibility | TextButton | ✅ |
| C6 | PaginationFooter | Spinner / retry row at list end | CircularProgress, TextButton | ✅ |
| C7 | InboxErrorPanel | First-load error + Retry | ErrorPanel (shared) | ✅ |
| C8 | TypeIcon | Per-type leading icon mapping | Icon (mapped in 11 §3) | ✅ |
| C9 | FilterChip (unread) | unreadOnly toggle | FilterChip (shared) (proposed) | ⏳ |
| C10 | SwipeToMarkRead | Dismissible wrapper on C1 | Dismissible (proposed) | ⏳ |
2. NotificationListTile Contract
NotificationListTile({
required Notification n, // model from `15_Flutter_Implementation_Guide`
required VoidCallback onTap, // marks read (if unread) + optional nav
})
- Displays:
n.title,n.body(2-line), relativen.createdAt,C8icon,C2dot iffn.readAt == null. - Unread style:
titleMediumbold + tinted surface; read:titleMediumregular. - Semantics: unread suffix (see
06 §1.7).
3. NotificationBadge Contract
- Input:
int count(0 hides). - Behavior:
99+ceiling;m-fastscale on change; semantics label. - Data source:
UnreadCountCubit(13_State_Management), refreshed on foreground + inbox mutations.
4. Reuse Notes
- Do not invent new buttons/inputs — reuse shared
AppButton,AppTextFieldetc. from00-shared/03. - No new stateful primitive widgets: C1/C3 hold state via Cubits, not local
widget state (
00-shared/06).
08 — Form Specifications (Notifications Module)
- 1. Implicit Inputs (no form UI)
- 2. Admin Manual-Send Form
(planned) - 3. Notification Preferences Form
(planned) - 4. Form Rules Summary
Notifications are system-generated — there is no user-facing creation form in v1. The only user inputs are implicit (taps/actions) and two query params. This file documents those plus the (planned) admin form, since a send endpoint is named in the blueprint.
1. Implicit Inputs (no form UI)
1.1 List query — GET /api/v1/notifications
| Param | Type | Constraints | Default | Source |
|---|---|---|---|---|
page | int | ≥ 1 | 1 | list-notifications.dto.ts:6-11 |
limit | int | 1..100 | 20 | list-notifications.dto.ts:13-19 |
unreadOnly | bool | true/'true' coerced | absent | list-notifications.dto.ts:21-25 |
Invalid params → 400 via global validation pipe (main.ts). Client never sends
unreadOnly=false (omit instead).
1.2 Read actions (no payload)
PATCH /api/v1/notifications/:id/read—:idis a Mongo ObjectId.PATCH /api/v1/notifications/read-all— empty body.- Ownership: cross-user
:id→200 null(notifications.service.ts:54-59), treated as no-op client-side.
2. Admin Manual-Send Form (planned)
Endpoint exists only in the blueprint — POST /api/v1/notifications/send
(internal/service) (Notifications.md:24). Design when notification.send
enters ALL_PERMISSIONS (currently absent — permissions.constants.ts).
| Field | Type | Rules (proposed, from blueprint semantics) |
|---|---|---|
type | select | from server enum; unknown → 400 |
title | text | required, ≤ 120 chars |
body | textarea | required, ≤ 500 chars |
recipientIds | multi-select | ≥ 1 user; tenant-scoped |
data | JSON | optional, opaque (schema:28-29) |
| submit | — | async via BullMQ, never sync (Notifications.md:57) |
3. Notification Preferences Form (planned)
GET/PATCH /api/v1/notifications/preferences (Notifications.md:27-28); the
notification_preferences collection does not exist yet (Notifications.md:7).
| Field | Type | Rules (proposed) |
|---|---|---|
| channel toggles (email/push/in-app) | switches | per-user, per-type optionality |
| Save | — | PATCH with partial update; optimistic UI |
4. Form Rules Summary
- v1 shipped forms: none. All mutations are idempotent-ish actions; do not build form scaffolding for planned screens (01 §6, ponytail).
- Validation mirrors server DTOs — client pre-validates only
page/limitbounds to avoid 400 round-trips.
09 — User Behaviour (Notifications Module)
- 1. Badge-First Behaviour
- 2. Read-on-Tap
- 3. Mark-All-Read Session
- 4. Ordering Expectation
- 5. Notification Fatigue (planned-feature guard)
- 6. Time Perception
- 7. Deep-Link Navigation (proposed)
- 8. Error Resilience
Behavioural patterns observed/assumed for the inbox, grounded in the API contract (
notifications.controller.ts) and the event fan-in (notifications.handler.ts).
1. Badge-First Behaviour
- Users judge the inbox by the badge number before opening it; they expect
the number to equal what the list shows. Contract requirement:
unread-count(controller.ts:31-35) must agree with the count ofreadAt == nullrows in the list — same source collection, same filter (repository.ts:31-35vsrepository.ts:45-50), so it is consistent by construction. Client must still reconcile after mark-read mutations (see 13_State_Management).
2. Read-on-Tap
- Typical pattern: tap item → mark read → return. The read flag must update
optimistically on the tile and the badge; server
200 null(foreign id) must not crash the tile (service.ts:54-59).
3. Mark-All-Read Session
- Users batch-clear the inbox when unread exceeds ~10. After
read-all, they expect the badge to drop to 0 immediately; partial failures are invisible server-side (bulk update has no per-item error —repository.ts:45-50). Client treats the response as success if the request succeeds.
4. Ordering Expectation
- Users read newest-first and assume page 1 = today's items. Server sorts
createdAt: -1(repository.ts:66); never client-sort.
5. Notification Fatigue (planned-feature guard)
- Per
docs/IMPLEMENTATION_PLAN.md:769coaching tenants will generate high-volume events (test reminders, DPP due alerts, batch starts). Without per-type preferences (Notifications.md:27-28— (planned)) the inbox will flood. V1 mitigation: only four account-lifecycle events are wired today (handler.ts:8-50) — the event map is the volume governor. Add prefs before fanning out homework/exam events (14_QA_Checklist §G).
6. Time Perception
- Relative timestamps ("5m ago") dominate; absolute timestamps only in
accessibility text. Derived from
createdAt(timestamps: true,schema:14).
7. Deep-Link Navigation (proposed)
- Users expect a notification tap to open the thing it announces. Today
datais the raw event payload (handler.ts:96— e.g.{ userId }), with notargetUrlconvention. Until v2 definesdata.targetUrl, most taps only mark read — acceptable for v1 account-lifecycle content (welcome/verify/reset).
8. Error Resilience
- Users retry after transient failures; pagination failure must not nuke the loaded list (footer retry, 06 §1.4). Offline first-load shows cached copy when available (00-shared/06 hydration).
10 — Interaction Specification (Notifications Module)
- 1. Item Tap (mark read + optional navigate)
- 2. Unread Badge Mutations
- 3. Mark All Read
- 4. Pull-to-Refresh
- 5. Infinite Scroll
- 6. Unread Filter (proposed v1.1)
- 7. Swipe-to-Mark-Read (proposed)
- 8. Keyboard / Shortcut (desktop)
- 9. Motion Summary
Precise interaction rules for the inbox + badge. Motion tokens from 00-shared/08; accessibility baseline 00-shared/09.
1. Item Tap (mark read + optional navigate)
- Tap tile → if
readAt == null: optimistic flip to read (dot off, weight down,m-fast) → firePATCH /api/v1/notifications/:id/read(controller.ts:37-41). - Response
200with document → reconcile (no visual change needed). - Response
200 null(not owner) → revert? No — treat as already-read; remove from unread scope. - Error → retry once silently; then snackbar "Couldn't update" and revert tile.
- If
datacarries a recognized route hint (proposeddata.targetUrl) → navigate after successful mark; v1 has no hint → no navigation.
2. Unread Badge Mutations
| Trigger | Effect |
|---|---|
| tap item (unread) | decrement on optimistic flip; server fail → re-increment |
| mark-all success | → 0 immediately |
| inbox opened | refetch count; reconcile with list-derived count if drift |
| app foreground / login | refetch count |
| new notification arrives | count+1 (v1: on next fetch — no WS push, 01 §3) |
3. Mark All Read
- Enabled iff
unread > 0and no in-flight request. - Fire
PATCH /api/v1/notifications/read-all(controller.ts:43-47) → on success: badge 0, all tiles read (m-fastbatch, no per-tile animations), snackbar confirmation. - Failure: revert, snackbar with Retry.
4. Pull-to-Refresh
- Refetch page 1 (and unread-count in parallel). Replace list on success; keep
scroll offset when
meta.totalItemsunchanged.
5. Infinite Scroll
- Trigger: last visible index ≥
data.length - threshold(4)andmeta.hasNext. - Fetch
page+1; append; dedupe by_id(defensive — server pagination is offset-based, stable under insert-on-top drift can duplicate a row across pages; dedupe client-side, see 14 §B).
6. Unread Filter (proposed v1.1)
- Chip toggle → reset to page 1 with
unreadOnly(dto:21-25); chip shows active state; empty-with-filter shows "all caught up" (05 §3).
7. Swipe-to-Mark-Read (proposed)
Dismissibleend-dismiss (m-fast) → same as §1 (optimistic + API), no navigate. Do not allow swipe on already-read tiles.
8. Keyboard / Shortcut (desktop)
m= mark all read when inbox focused;j/kmove focus;Enter= tap. (proposed) — adaptivity in 00-shared/11.
9. Motion Summary
| Interaction | Token | Note |
|---|---|---|
| item read flip | m-fast | dot fade + weight change, no slide |
| first-load stagger | m-base | rows fade, 40 ms stagger |
| badge count change | m-fast | scale 1→1.15→1 |
| page swap (filter/refresh replace) | m-base | crossfade list |
| snackbar confirmations | m-base | standard toast |
11 — Design System Mapping (Notifications Module)
- 1. Color
- 2. Typography
- 3. Iconography
- 4. Spacing / Shape
- 5. Motion
- 6. Component Mapping
- 7. Branding Note
Maps the module onto 00-shared/02 (Design Tokens) and 00-shared/04 (Design System Mapping). No new tokens.
1. Color
| Usage | Token (from 00-shared/02) | Note |
|---|---|---|
| Unread dot / badge | color.primary (or accent per brand) | only non-text indicator; must pair with weight change (06 §1.7) |
| Unread tile surface | surfaceVariant tint / color.primary at low alpha | subtle, not a full card fill |
| Title (unread) | textPrimary bold; (read) textPrimary regular | — |
| Body | textSecondary | both states |
| Badge count | onPrimary on primary | ≥ 3:1 contrast (00-shared/09) |
| Empty state / error panel | textSecondary + surface | standard shared patterns |
2. Typography
| Element | Token |
|---|---|
| AppBar title | titleLarge |
| Tile title | titleMedium (weight varies by read state) |
| Tile body | bodyMedium (2-line clamp) |
| Timestamp | labelSmall (textSecondary) |
| Badge count | labelSmall bold |
3. Iconography
type (server enum) | Icon | Fallback |
|---|---|---|
welcome | celebration/star | — |
email_verified | verified badge/check | — |
verification_resent | mail-send | — |
password_reset | lock/rotate | — |
| unknown | info/bell | generic system icon — client must handle future enum values (04 §3) |
Mapping lives in one client file (see 15_Flutter_Implementation_Guide §7).
4. Spacing / Shape
- Tile padding:
space-16; icon 40 dp roundedradius-m; gap 12. - List max width 640 dp centered; bottom inset respects system nav bar.
- Badge: 18 dp min height pill,
radius-full.
5. Motion
- All module motion via 00-shared/08 tokens:
m-fast(read flip, badge),m-base(list fade, page swap). No custom curves.
6. Component Mapping
| Module component (07) | Shared primitive |
|---|---|
| C1 NotificationListTile | ListTile + Text + Icon from 00-shared/03 |
| C3 NotificationBadge | Badge + IconButton (shared) |
| C4 InboxEmptyState | EmptyState (shared) |
| C7 InboxErrorPanel | ErrorPanel (shared) |
7. Branding Note
This module introduces no brand assets; notification icons reuse the shared icon set. Future push-channel icons/illustrations go through 00-shared/02.
12 — API Mapping (Notifications Module)
- 1. Implemented Endpoints
- 2. Permissions
- 3. Planned / Forward-Looking Endpoints
- 4. Event → Notification Creation Contract (no HTTP)
- 5. Queue-Event Routing (infrastructure)
Exact contract for every endpoint. Base path
/api/v1(URI versioning,src/main.ts). All routes require a JWT (@UseGuards(JwtAuthGuard),notifications.controller.ts:16); success envelope via shared interceptor (common/interceptors/response-envelope.interceptor.ts), errors viacommon/filters/http-exception.filter.ts(see 00-shared/07).
1. Implemented Endpoints
1.1 GET /api/v1/notifications — List my notifications
- Source:
notifications.controller.ts:21-29→service.findMyNotifications(notifications.service.ts:39-52) - Query:
page(int ≥1, default 1),limit(int 1..100, default 20),unreadOnly(bool) —dto/list-notifications.dto.ts:6-25 - Response 200:
{ "data": [ { "_id", "recipientId", "type", "title", "body", "data": {}, "readAt": null, "createdAt", "updatedAt", "version" } ], "meta": { "page", "limit", "totalItems", "totalPages", "hasNext", "hasPrevious" } }metashape frombuildPaginationMeta(common/dto/pagination-query.dto.ts:41-55). - Ordering:
createdAtdesc (repository.ts:66). Filter: tenant + recipient + optionalreadAt: null(repository.ts:52-73). - Errors: 400 (bad query), 401 (no token).
1.2 GET /api/v1/notifications/unread-count — Unread count
- Source:
notifications.controller.ts:31-35→service.getUnreadCount(service.ts:66-70) →repo.countUnreadByRecipient(repository.ts:31-35) - Response 200:
{ "count": 3 }
1.3 PATCH /api/v1/notifications/:id/read — Mark one read
- Source:
notifications.controller.ts:37-41→service.markAsRead(service.ts:54-59) - Ownership:
recipientId !== userId→200 null(no-op, not 404). - Response 200: updated document (with
readAt,version+1—repository.ts:37-43);nullfor foreign/missing id. - Route note: two-segment
:id/readcannot collide withread-all(single segment) — keep this ordering on any future param route.
1.4 PATCH /api/v1/notifications/read-all — Mark all read
- Source:
notifications.controller.ts:43-47→service.markAllAsRead(service.ts:61-64) → bulkupdateManywherereadAt: null(repository.ts:45-50) - Response 200:
{}(envelope with no payload).
2. Permissions
| Permission | Constants | Used by |
|---|---|---|
notification.read | permissions.constants.ts:32 | list / unread-count (planned RBAC binding) |
notification.update | permissions.constants.ts:33 | mark-read endpoints (planned RBAC binding) |
notification.send (internal) | not in ALL_PERMISSIONS | POST /notifications/send — (planned), blueprint Notifications.md:24,68-70 |
notification.preference.manage | not in ALL_PERMISSIONS | preferences — (planned), blueprint Notifications.md:70 |
Note: no RBAC guard is attached today — only
JwtAuthGuard; perms are declared-but-unenforced (gap, see14 §D).
3. Planned / Forward-Looking Endpoints
| Endpoint | Status | Source |
|---|---|---|
POST /api/v1/notifications/send (internal) | (planned) | Notifications.md:24 |
GET /api/v1/notifications/preferences | (planned) | Notifications.md:27 |
PATCH /api/v1/notifications/preferences | (planned) | Notifications.md:28 |
GET /api/v1/notification-templates | (planned) | Notifications.md:29 |
POST /api/v1/notifications/device-token | (future) — (forward-looking) | END_TO_END_USER_FLOWS.md:788 |
4. Event → Notification Creation Contract (no HTTP)
| Domain Event | NotificationType | Title | Source |
|---|---|---|---|
EmailVerified | email_verified | Email Verified | handler.ts:16-20 |
EmailVerificationResent | verification_resent | Verification Email Sent | handler.ts:21-25 |
PasswordResetCompleted | password_reset | Password Reset | handler.ts:26-30 |
UserRegistered | welcome | Welcome to StudyLyon (interpolates firstName, handler.ts:44-47) | handler.ts:41-49 |
Recipient resolution: payload.userId ?? payload.recipientId ?? actorId (handler.ts:76-79).
data = full event payload (handler.ts:96). NotificationCreatedEvent interface
(events/notification-events.ts:1-10) is declared but never emitted (gap 14 §F).
5. Queue-Event Routing (infrastructure)
QUEUE.EMAILS/PUSH/WHATSAPP/IN_APPconstants exist (queue.constants.ts:1-17).event-queue-map.ts:6-43routes events:emails(welcome/reset/invoice/receipt),in-app(UserCreated, OrganizationCreated, Homework*, ExamResultsPublished, Student/Teacher/Staff/ParentCreated, FeeStructureCreated),audit-write, etc.- Gap: no BullMQ worker consumes the
in-appqueue for notifications;NotificationsHandlerlistens on the in-processEventBusonly (handler.ts:61). The map is the (planned) fan-out contract — see14 §G.
13 — State Management (Notifications Module)
Flutter client. App-wide conventions in 00-shared/06 (State Management) and 00-shared/11 (Flutter App Architecture); this file is the module slice.
1. Cubits
1.1 NotificationsCubit — inbox list
| State | Fields |
|---|---|
NotificationsInitial | — |
NotificationsLoading | — (first page) |
NotificationsLoaded | items, page, hasNext, totalItems, loadingMore, unreadOnly |
NotificationsEmpty | unreadOnly (drives copy) |
NotificationsError | message, retryable |
Events
LoadFirstPage({ bool unreadOnly })— reset + fetch?page=1&limit=20[&unreadOnly].LoadMore()— guarded byhasNext&& !loadingMore; appends, dedupes by_id(offset pagination can duplicate on insert-on-top, 10 §5).MarkOneRead(id)— optimistic flip, thenPATCH /:id/read; revert on error.MarkAllRead()— optimistic clear, thenPATCH /read-all; revert on error.Refresh()— reload page 1, keep scroll position.
Dependencies: NotificationsApi (see 15 §4), UnreadCountCubit (count
reconciliation after mark mutations).
1.2 UnreadCountCubit — shell badge
| State | Fields |
|---|---|
UnreadCountState | count (int ≥ 0), loaded (bool — false → hide badge silently, 06 §3) |
Events: Refresh() (GET /unread-count), Decrement() (optimistic, from
NotificationsCubit), Reset() (after mark-all), Increment() (forward-looking:
realtime via WebSocket /ws — END_TO_END_USER_FLOWS.md:773-775).
Refresh triggers: app foreground, inbox open, after any mark mutation (server value wins over optimistic).
2. State Flow
flowchart TD
A[AppShell] -->|badge| UC[UnreadCountCubit]
A -->|"GET /unread-count"| UC
B[InboxScreen] --> NC[NotificationsCubit]
NC -->|"GET /notifications?page&limit&unreadOnly"| API
subgraph Mutations
NC -->|"PATCH /:id/read"| API
NC -->|"PATCH /read-all"| API
end
API -->|"envelope {data, meta}"| NC
NC -->|"onMutation success → Refresh"| UC
UC -->|count| B[Badge Widget]
NC -->|items| T[NotificationListTile]
3. Consistency Rules
- Server is truth after every mutation: optimistic UI → request → reconcile
with response (doc
readAt, or freshunread-count). - Badge = server count; optimistic decrements are cosmetic and re-synced on foreground/inbox-open.
unreadOnlyfilter change resets the list (newLoadFirstPage).- Pagination state lives in the Cubit, never in widget locals
(
00-shared/06— single source per feature). - Offline hydration: cache last
Loadedvia app-wide cache layer (00-shared/06 §hydration); refresh on reconnect.
4. What NOT to store in state
- Relative-time strings (recomputed on rebuild).
- Icon mappings (pure functions in one file —
11 §3). - Auth token / tenant (shell concerns,
00-shared/06).
14 — QA Checklist (Notifications Module)
- A. Ordering & Pagination
- B. Mark-Read Semantics
- C. Tenant & Soft-Delete Isolation
- D. Auth & RBAC
- E. Announcement Overlap
- F. Event Fan-Out & Idempotency
- G. Volume & Fatigue (planned-feature gate)
- H. Client UX Checks
Verify against
notifications.controller.ts,notifications.service.ts,repositories/notifications.repository.ts,notifications.handler.ts. Shared QA baseline in 00-shared/10.
A. Ordering & Pagination
-
List returns
createdAtdesc — first page newest first (repository.ts:66). -
metamath:totalPages = max(1, ceil(total/limit)),hasNext = page < totalPages,hasPrevious = page > 1(pagination-query.dto.ts:46-54). -
limitclamp:1..100(list-notifications.dto.ts:13-19);page ≥ 1. -
Empty total →
{ data: [], meta: { totalItems: 0, totalPages: 1, hasNext: false } }— not an error. -
Insert-on-top between page fetches: offset pagination can duplicate rows across pages — client dedupes by
_id(10 §5); confirm no server crash onskip> collection. -
unreadOnly=truereturns onlyreadAt: null(service.ts:45);false/absent returns all.
B. Mark-Read Semantics
-
PATCH /:id/readsetsreadAt+ incrementsversion(repository.ts:40). -
Cross-user id →
200 null, never 404/403 and never touches the row (service.ts:57). -
Already-read id → idempotent re-mark is harmless (
findOneAndUpdate). -
read-allonly updatesreadAt: nulldocs (repository.ts:46-49). -
Race: user taps item while
read-allis in flight → both succeed; final state read. Client must not double-decrement badge (reconcile via server count, 13 §3).
C. Tenant & Soft-Delete Isolation
-
All queries pass through
scopedFilter(tenantId + deletedAt) viaBaseRepository— verify a second tenant's notifications are invisible (repository.ts:24-26,38-39,47-48,61). -
Handler writes under synthetic tenant context from the event (
handler.ts:81-99) — tenantId must come from the event, never ambient state. - Soft-deleted recipient docs: notifications still queryable by id (hard delete not cascaded) — document intended behaviour.
D. Auth & RBAC
-
All 4 routes reject unauthenticated requests (
controller.ts:16). -
Gap:
notification.read/notification.updatedeclared (permissions.constants.ts:32-33) but no RBAC guard attached — confirm intended (JWT-only for v1) and log decision. -
(planned)
notification.sendmust be added toALL_PERMISSIONSbefore any send endpoint ships.
E. Announcement Overlap
-
AnnouncementPublished(announcement.service.ts:80-91) does not create inbox notifications today — confirm product intent (separate read receiptsannouncement.service.ts:95-102vs inbox). If inbox fan-out is wanted, map the event inEVENT_TYPE_MAPand define recipient expansion (audience resolution is inannouncement.service.ts:109-148).
F. Event Fan-Out & Idempotency
-
Handler is
onAny— only mapped types create notifications; unknown events are no-ops (handler.ts:71-74). Adding a new event requires an explicit map entry (feature toggle, 09 §5). -
Gap:
NotificationCreatedEventinterface exists (events/notification-events.ts:1-10) but is never emitted — either emit it (audit/websocket use) or delete it. -
Gap:
event-queue-map.ts:10-40routes ~18 events to thein-appBullMQ queue with no worker — creating notifications for them is (planned); do not half-wire (queue without worker = silent loss, violatesNotifications.md:57). -
Retry/DLQ for notification creation: handler failures are logged only (
handler.ts:62-67) — no retry today. Acceptable for in-process events; revisit with queue-based fan-out. -
Duplicate events (e.g. double
EmailVerifiedemit) create duplicate notifications — document idempotency key if dedupe is required.
G. Volume & Fatigue (planned-feature gate)
-
Before fanning out homework/exam/fee events, ship per-type preferences (
Notifications.md:27-28) — see 09 §5. -
Coaching push (test reminders, DPP, batch start) is gated on mobile-app decision (
IMPLEMENTATION_PLAN.md:859).
H. Client UX Checks
-
Badge ≤ 99+ display; hidden on
loaded=false(06 §2). - Badge agrees with list unread count on inbox open.
- Offline first-load → error panel + retry; pagination failure → footer retry, list intact (06 §1.6).
- a11y: unread suffix semantics, badge label, contrast (00-shared/09).
-
Analytics events
{module}.{screen}.{action}wired (proposed) (00-shared/10 §8).
15 — Flutter Implementation Guide (Notifications Module)
- 1. Data Model
- 2. Pagination Envelope
- 3. API Client
- 4. Repository
- 5. Cubits
- 6. Widgets
- 7. Type→Icon Map
- 8. Badge Integration
- 9. Deep Links (proposed)
- 10. Push / FCM (forward-looking — do not implement in v1)
Client implementation guide. App architecture baseline in 00-shared/11, state conventions in 00-shared/06. All endpoints from 12_API_Mapping.
1. Data Model
class Notification {
final String id; // _id
final String recipientId;
final String type; // server enum string; render unknown types generically
final String title;
final String body;
final Map<String, dynamic>? data;
final DateTime? readAt; // null = unread
final DateTime createdAt;
bool get unread => readAt == null;
}
fromJson must tolerate data = null/absent (notification.schema.ts:28-29).
2. Pagination Envelope
class PageMeta {
final int page, limit, totalItems, totalPages;
final bool hasNext, hasPrevious;
}
Parsed from meta (pagination-query.dto.ts:32-39); deserialize data + meta
inside the shared envelope (00-shared/07).
3. API Client
| Method | Call |
|---|---|
fetchInbox({page, limit, unreadOnly}) | GET /api/v1/notifications |
fetchUnreadCount() | GET /api/v1/notifications/unread-count → {count} |
markRead(id) | PATCH /api/v1/notifications/:id/read — treat 200 null as success no-op |
markAllRead() | PATCH /api/v1/notifications/read-all |
Base path /api/v1 from app config; auth header via shared client
(00-shared/11 §auth).
4. Repository
NotificationsRepository(domain layer): thin wrapper over the API client; holds the Cubits' data sources; no widget imports.- Client-side dedupe by
idon append (offset pagination drift,10 §5).
5. Cubits
NotificationsCubit + UnreadCountCubit per 13_State_Management — states,
events, and the consistency rules (optimistic → reconcile with server truth)
are specified there verbatim.
6. Widgets
| Widget | File suggestion | Builds |
|---|---|---|
NotificationInboxScreen | features/notifications/inbox_screen.dart | AppBar (title + MarkAllReadAction), list, PaginationFooter, EmptyState, ErrorPanel |
NotificationListTile | features/notifications/widgets/notification_list_tile.dart | C1 (07) |
NotificationBadge | features/notifications/widgets/notification_badge.dart | C3 — used by AppShell |
notification_icons.dart | features/notifications/widgets/notification_icons.dart | type→icon map (11 §3) |
- List:
ListView.builder+ScrollControllernear-end detection (0.7 * maxScrollExtent) →LoadMore. - Pull-to-refresh:
RefreshIndicator→Refresh()event. - Tap: optimistic
MarkOneReadthen optional nav viadata.targetUrl(proposed) — v1 no navigation.
7. Type→Icon Map
IconData iconFor(String type) => switch (type) {
'welcome' => Icons.celebration_outlined,
'email_verified' => Icons.verified_outlined,
'verification_resent' => Icons.mail_outline,
'password_reset' => Icons.lock_reset,
_ => Icons.notifications_outlined,
};
8. Badge Integration
- AppShell owns
UnreadCountCubit; refreshes on foreground viaWidgetsBindingObserver. - After inbox mutations,
NotificationsCubitemits →UnreadCountCubit.Refresh(). - Hide badge when
loaded == false.
9. Deep Links (proposed)
- Route
/notificationsregistered in the shell router. data.targetUrlconvention (v2): tap → mark read →go_router.push(targetUrl). ValidatetargetUrlagainst an allowlist before navigation.
10. Push / FCM (forward-looking — do not implement in v1)
Blocked on: device-token endpoint (END_TO_END_USER_FLOWS.md:788), mobile-app
decision (IMPLEMENTATION_PLAN.md:859), channel prefs (Notifications.md:27-28).
When unblocked: register token after login, map data payload → notification
tap → same deep-link path as §9.
01 — Product Overview (Organizations Module)
- 1. Purpose
- 2. Business goals
- 3. User goals
- 4. Stakeholders
- 5. Why this exists
- 6. Dependencies (module + platform)
- 7. Success metrics
- 8. Edge cases
- 9. Assumptions
- 10. Assumptions & Open Questions
- 11. Glossary
StudyLyon — multi-tenant ERP / School Management API. This package designs the Organizations module client (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, events, queue routes, and wire contracts are derived directly from
src/modules/organizations/**,src/modules/settings/**,src/modules/feature-flags/**,src/modules/rbac/**,src/modules/auth/**,src/infrastructure/workers/tenant-purge.worker.ts, andstudylyon-blueprint/04-Modules/Organizations.md. No feature is invented; gaps are flagged in §10 and the module Assumptions & Open Questions.
1. Purpose
Organizations is the tenant root of the platform: it owns the lifecycle of every
institution (creation, profile, branding, settings, feature flags, subscription, offboarding)
and, together with Auth's register, provisions the tenant's first administrator. It is the
foundation every other module's data hangs off — every business document carries the
tenant's tenantId (slug) and inherits its configuration (timezone, currency, academic year,
attendance rules, theme, feature toggles).
| Responsibility | Source |
|---|---|
| Tenant (institution) creation + provisioning (default roles seeded) | organizations.service.ts:37 create() |
| Slug + domain uniqueness (platform-wide, not tenant-scoped) | organizations.service.ts:40-50; organization.schema.ts:32-51 |
| Org profile (contact, address, timezone, currency, academic year, logo) | organizations.schema.ts:28-155 |
| Org embedded settings (attendance rules, academic, theme) | update-organization-settings.dto.ts:4-30; organizations.controller.ts:56-72 |
Org feature-flag map (metadata.featureFlags) | organizations.controller.ts:74-89; organizations.service.ts:139-154 |
| Standalone key/value settings per tenant (upsert, groups) | settings.controller.ts:24-53; setting.schema.ts:7-38 |
| Standalone feature flags per tenant (enabled/label/module) | feature-flags.controller.ts:23-58; feature-flag.schema.ts:7-27 |
| Subscription plan/status + lifecycle status | organization.schema.ts:6-25, 82-127 |
| Tenant offboarding: soft delete + async purge (30-day retention) | organizations.service.ts:156-159; tenant-purge.worker.ts:15-47 |
First admin: POST /auth/register binds user to tenant + org_admin | auth.service.ts:54-121; register.dto.ts:27-29 |
Domain event OrganizationCreated → in-app notification job | organizations.service.ts:59-66; event-queue-map.ts:13 |
2. Business goals
| Goal | Measure | Source |
|---|---|---|
| Tenant provisioning in one call, ready for first login | POST /organizations returns org; POST /auth/register returns usable JWT pair | organizations.service.ts:37-68; auth.service.ts:54-121 |
| Platform-wide uniqueness of identity handles | slug + domain unique across all tenants → 409 DUPLICATE_RESOURCE | organizations.service.ts:40-50,107-122 |
| Safe offboarding | soft delete immediate, hard purge after 30 days, idempotent worker | tenant-purge.worker.ts:15,26-47 |
| Config without hot-document writes | key/value settings + feature_flags collections, upsert semantics | setting.schema.ts:38; feature-flag.schema.ts:26 |
| Tenant-configurable UX | org branding + settings theme drive client primary override ((proposed) wire-up) | organization.schema.ts:114-120; update-organization-settings.dto.ts:26-29 |
| No cross-tenant data leakage | JWT tenantId claim; repositories inject scope; 403/404 never leak | base.repository.ts:20-30; MULTI_TENANCY.md §4 |
3. User goals
- Super Admin (platform): create tenants, review all tenants, suspend/delete offboarding tenants; never operate inside a tenant's data.
- Org Admin: see and maintain the institution's identity (name, slug, domain, contact, address, logo), timezone/currency/academic year, branding, settings groups, and feature flags from one place; invite a co-admin.
- Invited admin: accept invitation, log in, and inherit the same org-admin surface.
- Platform Ops: understand purge timing (30 days) and confirm offboarding consequences.
4. Stakeholders
Platform operator (SaaS vendor), Super Admin, Org Admins (multi-institution customers), incoming admins, support staff (slug/domain conflicts, purge complaints), billing (subscription plan/status), audit & security (soft-delete, isolation), engineering/design/QA consuming these docs.
5. Why this exists
Every school/coaching on the platform is a tenant. Without Organizations, there is no
tenant to scope anything to: auth registers users into a tenant, RBAC seeds roles per
tenant, settings/feature-flags are keyed by tenantId. This module is the first surface a
customer touches (Super Admin provisioning) and the first place misconfiguration (wrong
timezone, wrong working days) damages the whole institution's daily operation.
6. Dependencies (module + platform)
| Dependency | Role | Status |
|---|---|---|
| Auth (register/login) | binds first admin to tenant, org_admin role | implemented |
RBAC (seedDefaults, members) | seeds DEFAULT_ROLES per tenant at org creation | implemented (organizations.service.ts:58) |
| Settings module | standalone key/value config surface | implemented |
| Feature-flags module | standalone toggle surface | implemented |
BullMQ TENANT_PURGE queue | purge worker (30-day retention) | queue + worker implemented; enqueue (planned) — see OQ-5 |
Storage (STORAGE_PROVIDER) | logo upload → logoFileId / branding.logo | interface exists; providers (planned) |
EventBus → eventQueueMap | OrganizationCreated → in-app/org-created-notification | implemented (event-queue-map.ts:13) |
| Swagger | endpoint documentation | implemented (main.ts:61-63) |
7. Success metrics
- Tenant provisioning (org + first admin login) < 15 min (00-shared/01 §2).
POST /organizationsp95 < 300 ms (org insert + role seeding).- Zero slug/domain duplicates reach the DB (pre-check + unique index).
- Offboarding: soft-delete effective immediately; purge job idempotent; nothing purged before 30 days.
- No 5xx on settings/flag bulk saves;
settings+feature-flagsupsert always idempotent. - Cross-tenant reads structurally impossible (repository scope); tenant-list surface gated to platform — see OQ-4.
8. Edge cases
| Edge case | Behaviour | Source |
|---|---|---|
| Slug collision (auto or explicit) | 409 DUPLICATE_RESOURCE with the conflicting slug | organizations.service.ts:40-44 |
| Domain collision with another org | 409; update path checks existing.id !== id | organizations.service.ts:46-50,115-122 |
| No slug sent | derived from name via slugify (lowercase, strip, hyphenate) | organizations.service.ts:38,161-169 |
| Create never takes a status | forced ONBOARDING; activation via later PATCH | organizations.service.ts:55 |
PATCH :id/settings sends partial object | full-replace $set {settings} — missing groups are wiped; client must send all groups | organizations.service.ts:134 |
PATCH :id/feature-flags sends partial map | full-replace $set metadata.featureFlags — missing keys deleted | organizations.service.ts:150 |
Invalid ObjectId in :id | CastError → 400 VALIDATION_ERROR "Invalid resource identifier." | http-exception.filter.ts:47-55,92 |
| Delete of unknown/soft-deleted id | 404 RESOURCE_NOT_FOUND | organizations.service.ts:158 |
| Register with email already used | 409 "User with this email already exists." | auth.service.ts:56-58 |
| Register with unknown tenantId | accepted — no org-existence check in register (see OQ-2) | auth.service.ts:60-69 |
| Soft-deleted org | excluded from all queries (isDeleted:false filter) | organizations.repository.ts:15-19 |
| Purge runs while new soft-deletes exist | only deletes docs deletedAt older than 30-day cutoff; idempotent | tenant-purge.worker.ts:32-42 |
| Cross-tenant id access | 403/404, never leak existence | 07_API_Conventions.md §6 |
9. Assumptions
- Client scope flag (matches 00-shared/01 §9): native mobile apps are out of Phase 1
(
PRODUCT_REQUIREMENTS_DOCUMENT.md:144); these docs are forward-looking full client specs against the implemented API. Module-specific "forward-looking" marks apply to push/QR/deep links. - Organization surface is admin-only (
/organizationdestination, 00-shared/05 §2). - The tenant identifier in JWT and every tenant-scoped collection is the org slug string;
the org document's
_idis a separate ObjectId (see OQ-1 for the self-view gap). - Client-side validation (lengths, hex color regex, working-day ranges) is
(proposed)— the backend DTOs impose no min/max beyond@IsEmail, enums, and object types. (planned)items come fromstudylyon-blueprint/04-Modules/Organizations.mdanddocs/IMPLEMENTATION_PLAN.md(R2 provider, rate-limit tiers, institution type).- Analytics events are
(proposed)contract only (10_QA_Baseline.md §8).
10. Assumptions & Open Questions
| # | Item | Status / Impact |
|---|---|---|
| OQ-1 | No self-org endpoint. GET /organizations/:id needs the org _id, but JWT carries only the slug (auth.service.ts:461). Client cannot fetch its own org profile without a cross-tenant listing. Resolution (planned): GET /organizations/me (or slug-resolved lookup); until then client may use GET /organizations?q=<slug> as stopgap (see OQ-4 risk). | Org Overview screen |
| OQ-2 | register does not verify the org exists or is active (auth.service.ts:60-69). Any slug registers an admin. Client should surface "tenant not found" guidance; server validation (planned). | Registration journey |
| OQ-3 | OrganizationUpdated event interface exists (organization-created.event.ts:10-16) but is never emitted by update(). No audit signal for profile changes today. | Audit trail |
| OQ-4 | OrganizationsRepository is not tenant-scoped (organizations.repository.ts:15-19 — only isDeleted filter) and the controller has no JWT/RBAC guard (organizations.controller.ts:1-90). A tenant admin calling GET /organizations today sees every tenant (slug + domain). Client must gate the tenants list + tenant detail to platform admin only; server guard/scope (planned). | Isolation, tenants list |
| OQ-5 | Blueprint: "Offboarding triggers TenantPurgeJob" (Organizations.md:61) — but remove() only soft-deletes; nothing enqueues QUEUE.TENANT_PURGE. Worker purges all soft-deleted docs ≥30 days across all models. Enqueue + per-tenant purge payload (planned). | Offboarding |
| OQ-6 | Blueprint lists a separate organization_settings collection (Organizations.md:7); code keeps settings embedded in organizations.settings and a generic settings collection. Two surfaces exist; sync between them is unspecified. | Settings UI |
| OQ-7 | No organization.create permission in permissions.constants.ts:1-97 — create is platform-only by role design; client hardcodes it as such. | Tenants list |
11. Glossary
| Term | Meaning | Source |
|---|---|---|
| Tenant | One institution; identified by its slug in JWT + every tenantId field | MULTI_TENANCY.md §1 |
| Slug | Unique lowercase URL-safe handle; auto-derived from name | organizations.service.ts:161-169 |
| Organization status | onboarding / active / inactive / suspended | organization.schema.ts:20-25 |
| Subscription plan/status | free/basic/premium/enterprise; active/inactive/suspended/trial | organization.schema.ts:6-18 |
| Settings (embedded) | attendance {gracePeriod,lateThreshold,halfDayThreshold,workingDays} / academic {gradingScale,passingPercentage} / theme {primaryColor,secondaryColor} | update-organization-settings.dto.ts:4-30 |
| Feature flags (map) | metadata.featureFlags: Record<string,boolean> — tenant-level toggles | organizations.service.ts:139-154 |
| Settings (collection) | {tenantId,key,value,group} unique per tenant+key; groups academic/attendance/grading/notification/theme/general | setting.schema.ts:7-38 |
| Purge | Worker deleting all isDeleted docs older than 30 days (all models) | tenant-purge.worker.ts:15,32-42 |
| Envelope | {success,message,data,meta?,timestamp,requestId} | 07_API_Conventions.md §2-3 |
| Permission | resource.action strings incl. organization.*, settings.*, feature-flags.* | permissions.constants.ts:2-5,75-80 |
02 — User Personas (Organizations Module)
- P1 — Super Admin (platform)
- P2 — Org Admin
- P3 — Invited Admin
- P4 — Platform Ops (secondary)
- Persona × permission × surface matrix
Roles that touch the Organizations surface. Permissions are exact from
permissions.constants.ts+role.schema.ts. Note: noorganization.createpermission exists — provisioning is a platform-admin responsibility by role design (OQ-7 in 01). The client hides/denies surfaces per permission; server remains authoritative.
P1 — Super Admin (platform)
| Attribute | Value |
|---|---|
| Real-world role | Platform operator / SaaS vendor operations |
| System role | platform_admin (role.schema.ts:9-16) — priority 1000, permissions: [], operates via isPlatformAdmin bypass (jwt-auth.guard.ts:54; base.repository.ts:21-23) |
| Frequency | Daily (onboarding queue), bursts during migration/ops |
| Entry | /admin/tenants (platform surface, hidden from tenant admins) |
| Permissions (exact) | No explicit organization.* needed — platform bypass; ALL_PERMISSIONS available if role grants (not granted by default) |
Goals
- Provision a school in one call: name + optional slug/domain/contact/address/timezone/currency/plan/branding/metadata (
create-organization.dto.ts:57-112). - Oversee the whole tenant fleet: search by name/slug (
organizations.service.ts:78-83), sort, paginate. - Suspend problem tenants (
PATCH :id→status: suspended,update-organization.dto.ts:130-133) and offboard departed customers (DELETE :id, soft delete).
Pain points
- OQ-1: no self-org endpoint exists, but platform admin does have full
_idvisibility — pain is on the tenant side, not theirs. - Offboarding looks final but data survives 30 days (worker retention) — must communicate to customer.
- Slug/domain conflicts surface as raw 409 messages ("Organization with slug X already exists." —
organizations.service.ts:41-43).
Needs / restrictions
- Needs create/update/delete + list; must not operate tenant data from this surface.
- Restrictions: none enforced in code today for this controller (OQ-4) — client still gates by
isPlatformAdmin.
Mental model: "I open tenants, I create a tenant, I hand the slug to the customer for their admin registration." Expected behaviour: every lifecycle action immediate + reversible (soft delete); nothing hard-deleted for 30 days.
P2 — Org Admin
| Attribute | Value |
|---|---|
| Real-world role | Principal / Director / IT admin of one institution |
| System role | org_admin — priority 100, permissions: ALL_PERMISSIONS (role.schema.ts:17-24), including all organization.*, settings.*, feature-flags.* |
| Frequency | Daily (settings rarely; feature flags occasionally; profile quarterly) |
| Entry | /organization (self surface) |
Permissions (exact subset relevant to this module) — permissions.constants.ts:
| Permission | Line | Screens |
|---|---|---|
organization.read | :2 | Org Overview, Branding read |
organization.update | :3 | Org Edit, Branding, Academic year, Subscription |
organization.delete | :4 | Offboarding (self-delete — destructive, gated) |
organization.settings.update | :5 | Settings tabs (embedded settings path) |
settings.read / settings.update / settings.delete | :75-77 | Settings tabs (standalone settings collection path) |
feature-flags.read / feature-flags.update / feature-flags.delete | :78-80 | Feature flags screen |
user.create | :8 | Invite admin (user creation) |
rbac.member.create / rbac.member.read | :15-16 | Invite admin (membership) |
Goals
- Understand at a glance what the institution is configured as (timezone, currency, academic year, plan, status).
- Fix a mistake fast: wrong timezone, wrong working days, wrong late threshold — before the next attendance run.
- Make the app "feel like the school": logo + brand colors.
- Turn on/off features (e.g., biometric, WhatsApp) as contracts/priorities change.
Pain points
- OQ-6: settings exist in two places (embedded +
settingscollection) — risk of seeing stale/duplicated config. - OQ-1: no "my organization" endpoint — today the app cannot fetch the org profile without the org
_id. - Full-replace semantics: saving a partial settings/flag form silently wipes sibling groups/keys (
organizations.service.ts:134,150). PATCH :id/feature-flagsacceptsRecord<string,boolean>— no key whitelist, no label; the UI must maintain its own flag catalog.
Needs / restrictions
- Needs full read+write on self profile; delete is destructive → typed confirm.
- Must not see other tenants; must not see platform surface.
- Timezone/currency pickers over free text (server only validates
IsString—create-organization.dto.ts:86-94).
Mental model: "This is my school's identity page — like a settings app." Expected: save → instantly reflected app-wide (theme color, logo). Feature flags = light switches that affect whole modules.
P3 — Invited Admin
| Attribute | Value |
|---|---|
| Real-world role | Second administrator (e.g., vice principal, office manager) |
| System role | org_admin again, or a custom role with a permission subset (rbac.controller.ts:39-43) |
| Frequency | Daily; configure rarely |
| Entry | Invitation link/email → POST /auth/register (register.dto.ts:4-29) or login |
| Permissions | Whatever the org admin granted at invite (roles), evaluated server-side via getPermissionsForUser (rbac.service.ts:44-73) |
Goals
- Get in with zero friction: invite email → register (first/last name, email, phone, password ≥ 8) → usable session (register returns access+refresh pair —
auth.service.ts:95-99). - Be able to act on the org surface only where granted; see the same screens as P2 minus denied actions.
Pain points
- No invitation token mechanism exists in code (register is open; OQ-2) — invite = create user + add member role, then hand over credentials/registration.
- If registered with a mistyped tenant slug, they land in the wrong/no tenant silently.
Needs / restrictions
- Needs register form with tenant slug; needs
user.create+rbac.member.createfrom the inviting admin. - Restricted by their role's permission set — the client mirrors it and re-checks on 403.
Mental model: "The boss invited me; I just register and everything is already set up for me." Expected: onboarding state visible on first login if org is still onboarding.
P4 — Platform Ops (secondary)
| Attribute | Value |
|---|---|
| Real-world role | Support/DevOps |
| System role | Any platform role or direct DB/queue access |
| Frequency | Incident-driven |
| Entry | BullMQ dashboard (/admin/queues, main.ts:65-70) + tenant detail |
| Permissions | n/a (observability) |
Goals
- Verify purge timing: worker deletes only
isDeleted && deletedAt < now-30d(tenant-purge.worker.ts:32-42). - Replay
OrganizationCreatednotifications from thein-appqueue (event-queue-map.ts:13). - Diagnose "tenant disappeared" — soft-deleted orgs are invisible to every query (
organizations.repository.ts:15-19).
Persona × permission × surface matrix
| Surface | P1 Super Admin | P2 Org Admin | P3 Invited Admin |
|---|---|---|---|
Tenants list (/admin/tenants) | ✅ (platform flag) | ❌ hidden | ❌ hidden |
| Tenant detail + edit + suspend | ✅ | ❌ (own org only) | ❌ |
| Org Overview (self) | ✅ (via list) | ✅ organization.read | ✅ if role has it |
| Org Edit / Branding | ✅ | ✅ organization.update | role-gated |
| Settings tabs | ✅ (any tenant via :id) | ✅ organization.settings.update + settings.* | role-gated |
| Feature flags | ✅ | ✅ feature-flags.* | role-gated |
| Invite admin | ✅ | ✅ user.create + rbac.member.create | ❌ (unless custom) |
| Offboard (delete) | ✅ | ✅ organization.delete (typed confirm) | ❌ |
Server remains authoritative: 403 PERMISSION_DENIED / 404 RESOURCE_NOT_FOUND handling per 00-shared/06 §5.
03 — User Journeys (Organizations Module)
- 1. Tenant registration & onboarding (Super Admin → first admin)
- 2. Org profile edit
- 3. Branding configuration
- 4. Settings update (attendance / academic / theme)
- 5. Feature-flag toggling
- 6. Tenant offboarding / purge
End-to-end journeys computed from
organizations.controller.ts,organizations.service.ts,settings.controller.ts,feature-flags.controller.ts,auth.service.tsregister(), andtenant-purge.worker.ts. Each journey: entry, intent, decision points, system responses, loading, failures, recovery, exit, back nav, abandonment, timeout, session expiry, permission denial, offline, network loss, conflict, multi-device, deep links, push/QR/email entry.(planned)/(forward-looking)marks per global rules.
1. Tenant registration & onboarding (Super Admin → first admin)
entry: platform console → "New tenant"; or sales handoff with slug already agreed
intent: create a tenant and get its first admin into a working session
sequenceDiagram
actor SA as Super Admin
participant F as TenantCreateScreen
participant R as OrganizationsRepository
participant API as POST /api/v1/organizations
participant RBAC as RbacService
actor A as First Admin (Org Admin)
participant REG as POST /api/v1/auth/register
SA->>F: name + optional slug/domain/contact/address/timezone/currency/plan
F->>R: submit()
R->>API: CreateOrganizationDto
alt 201 success
API-->>R: org doc (status=onboarding, slug auto-derived) — organizations.service.ts:55
API-->>RBAC: seedDefaults(slug) — 7 DEFAULT_ROLES inserted — organizations.service.ts:58
Note over API: EventBus emits OrganizationCreated → in-app queue<br/>job org-created-notification — event-queue-map.ts:13
F-->>SA: success snackbar + org detail (with slug to hand over)
else 409 DUPLICATE_RESOURCE
API-->>R: "Organization with slug X already exists." — organizations.service.ts:41-43
F-->>SA: inline slug/domain conflict, suggest alternatives
else 400 VALIDATION_ERROR
F-->>SA: field-level errors (name required — create-organization.dto.ts:59-60)
else 429 / 5xx / network
F-->>SA: retry-able error state; draft preserved
end
SA-->>A: share slug + URL (email) — email entry (forward-looking: no invite email in code)
A->>REG: firstName, lastName, email, password(≥8), tenantId=slug — register.dto.ts:4-29
alt 201 success
REG-->>A: {accessToken, refreshToken} — auth.service.ts:95-99 (org_admin role)
Note over REG: user created with tenantId=slug; rbac.addMember(org_admin); seedDefaults — auth.service.ts:60-93
A-->>REG: verify email → land /organization (status onboarding banner)
else 409 email exists
A-->>REG: "User with this email already exists." — auth.service.ts:56-58
else 429 (register 5/min — auth.controller.ts:31)
A-->>REG: countdown, no auto-retry — 07_API_Conventions.md §4
end
- Decision points: explicit slug vs auto-slugify; plan (default
free—create-organization.dto.ts:96-102); domain (must be globally unique); whether to seed branding/metadata now or later. - Loading: submit button spinner, anti-double-submit; provisioning includes role seeding (may take ~100–300 ms).
- Failures/recovery: 409 → edit slug/domain inline, resubmit; 400 → field errors; offline → form blocked with guidance (
10_QA_Baseline.md §2), draft kept in memory. - Exit: success → tenant detail; back → list (draft lost — warn on unsaved).
- Abandonment: cancel mid-form → nothing persisted (create is atomic server-side; no partial org).
- Timeout/session expiry: 401 → silent refresh, fail → re-login preserving draft (
00-shared/06 §3.6). - Permission denial: non-platform user → route hidden + 403 screen.
- Conflict: two admins creating the same slug → second gets 409; retry with new slug.
- Multi-device: creation visible to other platform admins on next refresh/pull.
- Deep links / push / QR / email:
(forward-looking)invite email with slug link; QR for onboarding stations.
2. Org profile edit
entry: /organization → "Edit profile"
intent: fix name, slug, domain, contact, address, logo, timezone, currency, academic year
sequenceDiagram
actor OA as Org Admin
participant F as OrgEditScreen
participant R as OrganizationsRepository
participant API as PATCH /api/v1/organizations/:id
OA->>F: change name / timezone / academic year / contact
F->>R: submit() — full UpdateOrganizationDto
alt 200 success
API-->>R: updated doc (version incremented — organizations.repository.ts:49-58)
F-->>OA: success snackbar; header + org overview refresh
else 409 slug/domain conflict
API-->>R: "Organization with slug X already exists." — organizations.service.ts:107-122
F-->>OA: inline conflict on that field only
else 404
F-->>OA: "Organization not found." — organizations.service.ts:105,125
else 400 / 429 / network
F-->>OA: retryable error; form state preserved
end
- Entry points: org overview header, avatar menu → Organization, quick action.
- Decision points: changing
slugis identity-critical (breaks tenantId link for future registers — warn: "existing admins unaffected, new registrations must use the new slug"); domain must be unique. - Loading: skeleton on load, spinner on save. Back: unsaved-changes guard (dialog) → discard/keep editing.
- Timeout/session expiry/permission denial: standard (P2 needs
organization.update). - Offline: read from last-good cache, banner; write blocked with guidance (no offline queue defined for this module —
00-shared/07 §10). - Multi-device conflict: two admins edit → last write wins;
versionincrements but no optimistic-lock guard in repo (organizations.repository.ts:49-58—findOneAndUpdateunconditional); UI refreshes on focus.
3. Branding configuration
entry: /organization → "Branding" tab
intent: set brand colors + logo so the tenant app carries the school identity
sequenceDiagram
actor OA as Org Admin
participant F as BrandingScreen
participant API as PATCH /api/v1/organizations/:id
participant S as StorageProvider (planned)
OA->>F: pick primary/secondary colors (color picker), upload logo
alt logo upload
F->>S: upload → logoFileId (R2/Appwrite — IMPLEMENTATION_PLAN.md:24-34, (planned))
S-->>F: file id → branding.logo = id
end
F->>API: branding {primaryColor, secondaryColor, logo, favicon} — update-organization.dto.ts:125-128
API-->>F: 200 updated doc (branding is Record<string,unknown>, no validation — dto.ts:128)
F-->>OA: live theme preview updates primary color ((proposed) ThemeData copy — 04_Design_System_Mapping.md §7.5)
- Entry: branding tab; exit: back to org overview.
- Failure: upload failure → retry upload, form state kept; 400/429 standard.
- Offline: color changes are local-only until reconnect (no write queue — blocked).
- Permission denial: needs
organization.update. - Multi-device: branding change applies to all devices on next theme refresh; no realtime push today (
(forward-looking)WS topicorg.branding.updated).
4. Settings update (attendance / academic / theme)
entry: /organization/settings (tabs: general, attendance, academic, grading, notification, theme)
intent: adjust attendance rules (grace/late/half-day/working days), grading, colors
sequenceDiagram
actor OA as Org Admin
participant F as SettingsScreen (tab)
participant API as PATCH /api/v1/organizations/:id/settings
OA->>F: change gracePeriod 5→10, workingDays [1,2,3,4,5]→[0..5]
F->>API: FULL settings object {attendance, academic, theme} — full-replace semantics (organizations.service.ts:134)
alt 200
API-->>F: updated org doc (settings replaced)
F-->>OA: snackbar "Settings saved"; theme tab live-preview
else 400 VALIDATION_ERROR
F-->>OA: field errors (e.g., workingDays element not number)
else 404 / 429 / network
F-->>OA: retryable
end
- Critical: the API replaces the whole
settingsobject; the client form must always submit every group (merged from last-known server state) or sibling tabs' data is wiped (OQ-6,organizations.service.ts:134). - Parallel surface: standalone
PUT /api/v1/settings/bulk(settings.controller.ts:43-47) targets thesettingscollection — used by future module-specific config; keep the two surfaces visually separated ("Organization settings" vs "System settings") to avoid confusion. - Decision points: numeric bounds for
gracePeriod/lateThreshold/halfDayThresholdandworkingDays0–6 are(proposed)client-side — no server min/max (update-organization-settings.dto.ts:8-12). - Abandonment/timeout/offline/permission (needs
organization.settings.update): standard patterns as §2.
5. Feature-flag toggling
entry: /organization → "Feature flags" tab
intent: switch tenant-level capabilities on/off (biometric, SMS, WhatsApp, AI reports …)
sequenceDiagram
actor OA as Org Admin
participant F as FeatureFlagsScreen
participant API as PATCH /api/v1/organizations/:id/feature-flags
OA->>F: toggle biometrics ON, WhatsApp OFF
F->>API: FULL map {…existing, biometrics:true, whatsapp:false} — full-replace (organizations.service.ts:150)
alt 200
API-->>F: updated map (only boolean keys — controller.ts:84-88, dto-free)
F-->>OA: optimistic toggle + rollback on error (00-shared/06 §3.5); snackbar
else 400 (non-boolean value)
F-->>OA: inline error on the toggle row
else 404 / 429 / network
F-->>OA: rollback + retry snackbar
end
- Decision points: flag catalog (key + label + module) is client-maintained; the API has no key whitelist and
PATCH :id/feature-flagsaccepts anyRecord<string,boolean>. Unknown keys render from the standaloneGET /api/v1/feature-flagscollection (feature-flags.controller.ts:23-28) which also carries labels/descriptions (schemafeature-flag.schema.ts:15-22). - Optimistic: toggles are safe mutations → optimistic + rollback per
00-shared/06 §3.5. - Effect visibility: toggles gate other modules' UI via
FeatureFlagsCubit(00-shared/06 §4); propagation to other devices on next fetch ((forward-looking)WSorg.feature-flags.updated). - Permission: needs
feature-flags.update(org_admin has it; custom roles may not).
6. Tenant offboarding / purge
entry: tenant detail → "Delete tenant" (platform) or Org Overview → "Delete organization" (self)
intent: remove a tenant from the platform
sequenceDiagram
actor SA as Super Admin
participant F as TenantDetailScreen
participant API as DELETE /api/v1/organizations/:id
participant W as TenantPurgeWorker
SA->>F: open "Delete tenant"
F-->>SA: typed-confirm dialog (type org name — 00-shared/05 §5)
F->>API: DELETE :id
alt 200
API-->>F: 200 (remove returns void — organizations.service.ts:156-159)
F-->>SA: snackbar "Tenant deleted"; row disappears from list (soft-deleted — organizations.repository.ts:60-66)
Note over W: purge enqueue (planned, OQ-5). Worker: deletes all models' docs with<br/>isDeleted=true AND deletedAt < now−30d — idempotent — tenant-purge.worker.ts:32-42
Note over W: GDPR erasure path exists for users (eraseUser) — tenant-purge.worker.ts:27-30,49-56
else 404
F-->>SA: already gone → refresh list
else 403
F-->>SA: permission screen (needs organization.delete)
end
- Decision points: typed confirm (type org name) because the operation is destructive-ish (soft delete; reversible by DB restore within 30 days — no restore endpoint in code).
- Recovery: accidental delete → contact platform ops; hard purge after 30 days is irreversible.
- Loading: delete spinner on confirm; exit: back to tenants list (item gone).
- Timeouts/offline/conflicts: standard; multi-device: other admins see the tenant vanish on refresh.
- Email/push entry:
(forward-looking)offboarding confirmation email.
Journey × robustness matrix
| Journey | Permission denial | Offline | Timeout/session | Conflict | Multi-device |
|---|---|---|---|---|---|
| 1. Provision | 403 screen | write blocked, draft kept | refresh→re-login, draft kept | 409 slug | visible on refresh |
| 2. Profile edit | hidden action / 403 | read cache, write blocked | refresh→re-login | last-write-wins, no optimistic lock | refresh on focus |
| 3. Branding | 403 | local color pick only | refresh→re-login | last-write-wins | next theme refresh |
| 4. Settings | 403 | read cache, write blocked | refresh→re-login | full-replace wipe risk (submit-all-groups) | refresh on focus |
| 5. Flags | 403 + rollback | optimistic only (no offline queue) | refresh→re-login | last-write-wins map replace | next fetch |
| 6. Purge | 403 | blocked | refresh→re-login | double-delete → 404 → refresh | gone on refresh |
04 — Information Architecture (Organizations Module)
- 1. Navigation placement
- 2. Screen hierarchy (self surface)
- 3. Screen hierarchy (platform surface)
- 4. Modal hierarchy (per
00-shared/05 §5) - 5. Screen relationships
- 6. Routes & deep links
- 7. Nested nav & state survival
- 8. Quick actions, FABs, context menus
- 9. Global search integration
Module IA within the global shell (00-shared/05). The module owns two surfaces: the tenant self surface (Org Admin,
/organization) and the platform surface (Super Admin,/admin/tenants). Routes are(planned)— the client repo does not exist; they followgo_routerconventions from 00-shared/11 §6.
1. Navigation placement
| Surface | Destination | Shell slot | Visible to |
|---|---|---|---|
| Organization | /organization (Settings group, 00-shared/05 §2) | Drawer/rail: "Settings" section, above "Users & Roles" | Org Admin + custom roles with any organization.*/settings.* perm |
| Tenants (platform) | /admin/tenants | Drawer/rail: "Platform" section | isPlatformAdmin only |
- Both surfaces hidden and unroutable for everyone else (00-shared/05 §1).
- Tenants surface lives under a separate "Platform" group so tenant admins never see it.
2. Screen hierarchy (self surface)
/organization ──────────────── Org Overview (tab shell)
├── /organization/edit ────── Org Edit (full-screen form, >3 fields → page not sheet)
├── /organization/branding ── Branding (page w/ live preview)
├── /organization/settings ── Settings tabs (TabBar: General | Attendance | Academic | Grading | Notification | Theme)
│ └─ each tab = inline form (≤3-5 fields) — save per tab or one global save (see §5)
├── /organization/feature-flags ── Feature flags list (switch rows grouped by module)
└── /organization/members ──── Invite admin (form + members list) — cross-module (users + rbac)
└─ /organization/delete ──── (dialog, not route)
3. Screen hierarchy (platform surface)
/admin/tenants ─────────────── Tenants list (master)
├── /admin/tenants/:id ────── Tenant detail (master-detail right pane on ≥840 dp; push on phone)
│ ├── edit → /admin/tenants/:id/edit (reuses Org Edit screen, id from route)
│ ├── settings / branding / feature-flags (reuse self screens with :id)
│ └── delete (typed-confirm dialog)
└── /admin/tenants/new ────── Tenant create (wizard-lite: single full-screen form)
4. Modal hierarchy (per 00-shared/05 §5)
| Level | Used for | Widget |
|---|---|---|
| Snackbar | save success, toggle rollback, purge scheduled | AppSnackbar |
| Bottom sheet | timezone picker (searchable list), currency picker, color presets | AppBottomSheet |
| Dialog | unsaved-changes guard, delete confirm, status change confirm (suspend/activate), registration-slug handoff | AppDialog |
| Full-screen | Org Edit, Branding, Invite admin, Tenant create | Page routes |
| Loader | only global submit waits; screens prefer skeletons | AppProgress |
Destructive rules (00-shared/05 §5): organization.delete needs typed confirm (type the org name); suspend needs a plain confirm dialog with consequence copy.
5. Screen relationships
- Overview → Edit → back to Overview (refresh). Overview → Branding → back (refresh).
- Overview → Settings tabs: each tab fetches the same org doc (
GET :id/settings); one global "Save all" submits the merged fullsettingsobject (full-replace —organizations.service.ts:134). - Overview → Feature flags: fetches
GET :id/feature-flags(map) merged with standaloneGET /feature-flags(catalog: labels/descriptions/module). - Master-detail on tablet/desktop for
/admin/tenants(00-shared/05 §3); push-on-top on phone; breadcrumbs on desktop at depth ≥2 (00-shared/05 §3).
6. Routes & deep links
| Route | Screen | Guard |
|---|---|---|
/organization | Org Overview | authed + any organization.*/settings.* |
/organization/edit | Org Edit | organization.update |
/organization/branding | Branding | organization.update |
/organization/settings | Settings tabs | organization.settings.update or settings.* |
/organization/feature-flags | Feature flags | feature-flags.read + feature-flags.update |
/organization/members | Invite admin | user.create + rbac.member.create |
/admin/tenants | Tenants list | isPlatformAdmin |
/admin/tenants/new | Tenant create | isPlatformAdmin |
/admin/tenants/:id (+ /edit) | Tenant detail/edit | isPlatformAdmin |
Deep links:
studylyon://organization/settings— admin push to a setting ((forward-looking)push; route itself real).studylyon://admin/tenants/:id— support link to a tenant ((forward-looking)).
7. Nested nav & state survival
- Settings tabs use
StatefulShellBranch/TabBarViewkeep-alive (00-shared/03 AppTabs) so switching tabs preserves unsaved edits; switching bottom-nav destinations preserves tab state (00-shared/05 §3). - Feature-flags grouping by
modulefield (feature-flag.schema.ts:21-22) with collapsible sections (AnimatedSize,00-shared/08 §4).
8. Quick actions, FABs, context menus
| Screen | FAB | Quick action | Context menu (rows) |
|---|---|---|---|
| Tenants list | — (list is platform; row actions suffice) | Ctrl+K search already global; N new tenant (desktop, 00-shared/05 §7) | View · Edit · Suspend · Delete |
| Tenant detail | — | Edit (top bar) | — |
| Org Overview | — | Edit profile (top bar action) | — |
| Members | "Invite admin" | — | Remove member · Change role |
| Feature flags | — | — | Delete flag (standalone collection) |
- No FAB on self-surface overview (editing is toolbar-driven); the single FAB in this module is the optional "Invite admin" on members (
AppFABwithheroTagunique per screen,00-shared/03 §D).
9. Global search integration
- Tenants list:
qfiltersname/slugregex case-insensitive (organizations.service.ts:78-83), debounced 300 ms (AppSearchBar). - Platform admins can reach a tenant via global search only if the search backend indexes organizations
(planned).
05 — Screen Inventory (Organizations Module)
- S1 — Org Overview (self)
- S2 — Org Edit (self)
- S3 — Branding
- S4 — Settings tabs
- S5 — Feature flags
- S6 — Invite admin (members)
- S7 — Tenants list (platform)
- S8 — Tenant detail (+ create/edit reuse)
- Screen × data × analytics matrix
Every screen in the module. Data contracts derive from
organizations.controller.ts,settings.controller.ts,feature-flags.controller.ts, DTOs, and schemas. States follow 00-shared/06 §3.1 (initial/loading/success/error) + empty + offline + permission. Analytics events are(proposed)per 10_QA_Baseline.md §8 ({module}.{screen}.{action}).
S1 — Org Overview (self)
| Attribute | Value |
|---|---|
| Route | /organization |
| Purpose | Read-only institution identity summary + entry to all org actions |
| Entry | Drawer/rail "Organization"; post-login for admins; back-target of edit/branding/settings |
| Exit | Edit/Branding/Settings/Flags/Members; back → shell home |
| Permission | any organization.* or settings.* read |
Layout (phone / tablet+): AppBar "Organization" → hero header (logo AppAvatar, name, status chip, plan chip) → AppBanner if status === onboarding → section cards:
| Section | Cards/rows (from organization.schema.ts) | Widgets |
|---|---|---|
| Identity | name, slug, domain, logoFileId | AppCard + AppInfoRow |
| Contact | contact.email, contact.phone, contact.website (organization.schema.ts:53-58) | AppInfoRow |
| Address | street, city, state, country, zip (:60-67) | AppInfoRow |
| Localization | timezone (default UTC), currency (default USD) (:69-73) | AppInfoRow |
| Academic year | academicYear.startDate, endDate, month (:75-80) | AppInfoRow |
| Subscription | plan (free/basic/premium/enterprise), status (active/inactive/suspended/trial) (:82-94) | AppBadge plan + status |
| Settings summary | attendance workingDays, theme primaryColor (link → tabs) | AppListTile → /organization/settings |
| Feature flags count | count of enabled keys (link → flags) | AppListTile → /organization/feature-flags |
- Actions (top bar): Edit (
organization.update), Branding shortcut. - States: loading →
AppSkeletonlist; error →AppErrorState+ retry; empty/unknown → treated as 404 "organization not found"; offline → last-good cache +AppOfflineBanner. - Pull-to-refresh: re-fetch org (bypass cache).
- Keyboard/tablet/desktop: no inputs; tablet = 2-column info grid; desktop = wider grid + breadcrumb.
- A11y: one
headlineMedium; status chips = icon + label (never color-only, 09 §9); hero logoSemanticsname. - Analytics (proposed):
organizations.overview.view,organizations.overview.edit.tap,organizations.overview.branding.tap. - Data:
GET /api/v1/organizations/:id— id resolution: OQ-1 (no self endpoint;(planned)/me; stopgap: lookup via listq=<slug>).
S2 — Org Edit (self)
| Attribute | Value |
|---|---|
| Route | /organization/edit |
| Purpose | Edit name, slug, domain, contact, address, timezone, currency, academic year, subscription (read-only for non-billing roles) |
| Entry | Overview "Edit"; Exit: back (unsaved guard) / save → overview |
| Permission | organization.update |
Sections (form groups, full screen — >3 fields per 00-shared/05 §5):
- Identity: name, slug, domain (
update-organization.dto.ts:62-75); 2. Contact: email/phone/website (:85-87); 3. Address: street/city/state/country/zip (:89-94); 4. Localization: timezone (searchable sheet), currency (sheet); 5. Academic year: startDate/endDate (date pickers), month (number); 6. Subscription: plan + status dropdowns (org_admin) — read-only for custom roles without billing perms; (proposed) since no dedicated billing permission exists; 7. Branding link-out.
- Widgets:
AppTextField,AppDropdown,AppDatePicker,AppSearchBar(timezone/currency picker sheets). - States: load skeleton; save spinner on primary CTA "Save changes"; 409 → inline field conflict; 400 → field errors; offline → blocked; permission → CTA hidden.
- Animations: section entrance stagger
m-entrance(40 ms); unsaved guard dialogm-base. - Analytics (proposed):
organizations.edit.save.success|error,organizations.edit.slug.conflict.
S3 — Branding
| Attribute | Value |
|---|---|
| Route | /organization/branding |
| Purpose | Set branding {primaryColor, secondaryColor, logo, favicon} (organization.schema.ts:114-120; DTO update-organization.dto.ts:125-128) |
| Entry | Overview → Branding; Exit: save → overview |
| Permission | organization.update |
Widgets: live preview panel (theme card rendering current colors, (proposed) — applies primary override per 02_Design_Tokens.md §1), color picker (AppColorPicker — custom, (proposed)), logo upload (AppAttachmentTile upload → logoFileId, storage (planned) per IMPLEMENTATION_PLAN.md:24-34), favicon upload (optional).
- States: upload progress per file; save spinner; error on upload → retry tile; offline → pick colors locally, upload blocked.
- A11y: color picker also exposes hex text field (color-blind safe, 09 §9); preview
Semanticssummary. - Analytics (proposed):
organizations.branding.preview,organizations.branding.save.
S4 — Settings tabs
| Attribute | Value |
|---|---|
| Route | /organization/settings |
| Purpose | Edit embedded settings (attendance/academic/theme) + glance at standalone settings collection groups |
| Entry | Overview → Settings; Exit: back |
| Permission | organization.settings.update (save); settings.read for read-only tabs |
Tabs (from update-organization-settings.dto.ts:4-30 + setting.schema.ts:7-14):
| Tab | Fields | Source |
|---|---|---|
| General | (standalone collection group general) | setting.schema.ts:14 |
| Attendance | gracePeriod, lateThreshold, halfDayThreshold (minutes), workingDays[] (0–6) | update-organization-settings.dto.ts:8-12 |
| Academic | gradingScale, passingPercentage | :18-21 |
| Grading | (standalone group grading) | setting.schema.ts:10 |
| Notification | (standalone group notification) | :11 |
| Theme | primaryColor, secondaryColor | :26-29 |
- Widgets:
AppTabskeep-alive; numericAppTextField(number keyboard,(proposed)bounds: thresholds 0–300, workingDays 0–6 with day-chip multi-selectAppChips, passingPercentage 0–100); hex color fields. - Save model: one global "Save all" →
PATCH :id/settingswith full merged object (full-replace semantics —organizations.service.ts:134); per-tab autosave is forbidden (would wipe siblings). - States: per-tab loading skeleton; dirty-indicator per tab; save spinner; success snackbar; 400 field errors.
- Analytics (proposed):
organizations.settings.tab.view,organizations.settings.save.
S5 — Feature flags
| Attribute | Value |
|---|---|
| Route | /organization/feature-flags |
| Purpose | View + toggle tenant feature map |
| Entry | Overview → Feature flags; Exit: back |
| Permission | feature-flags.read (view), feature-flags.update (toggle) |
Content: merged view of (a) org map metadata.featureFlags (GET :id/feature-flags → Record<string,boolean>, organizations.service.ts:139-142) and (b) catalog GET /api/v1/feature-flags (feature-flags.controller.ts:23-28) for labels/descriptions/module. Rows grouped by module with collapsible sections; row = AppListTile + AppSwitch (optimistic).
- Actions: toggle → optimistic update, then
PATCH :id/feature-flagsfull map (organizations.service.ts:144-154); rollback + snackbar on error. Delete flag (standalone) →DELETE /feature-flags/:key(feature-flags.controller.ts:54-58). - Empty state:
AppEmptyState"No feature flags configured" (both lists empty). - Analytics (proposed):
organizations.flags.toggle.{key}.
S6 — Invite admin (members)
| Attribute | Value |
|---|---|
| Route | /organization/members |
| Purpose | Add an organization admin (cross-module: user + membership) |
| Entry | Overview → Members; Exit: back |
| Permission | user.create + rbac.member.create |
Content: member list (GET /api/v1/rbac/members, rbac.controller.ts:57-61) + invite form: firstName/lastName/email/phone/roles (user.create + rbac.member.create — rbac.controller.ts:63-67, add-member.dto roles). Row menu: change roles (PATCH /rbac/members/:id, rbac.controller.ts:69-73), remove (DELETE /rbac/members/:id, :75-79).
- Note: exact user-creation fields follow the Users module contract (
users.controller.ts:36-40) — see Users module docs. No invitation-token flow exists (OQ-2). - States: list skeleton; invite spinner; 409 duplicate email; empty "No members yet".
- Analytics (proposed):
organizations.members.invite,organizations.members.role.change.
S7 — Tenants list (platform)
| Attribute | Value |
|---|---|
| Route | /admin/tenants |
| Purpose | Browse all tenants (paginated) |
| Entry | Platform drawer; Exit: back |
| Permission | isPlatformAdmin only (OQ-4 — server guard (planned)) |
Content: AppSearchBar (q → name/slug regex, organizations.service.ts:78-83), sort dropdown (sort default -createdAt — :85-87; name/-name/createdAt), paginated list (page,limit default 20, pagination-query.dto.ts:5-30), infinite scroll + hasNext (buildPaginationMeta — :41-54), pull-to-refresh.
- Row: logo, name, slug, status chip, plan badge, created date. Context menu: View · Edit · Suspend · Delete.
- States: skeleton; empty ("No tenants match"); error+retry; offline cache + banner.
- Filters (proposed): status filter chip row (
organization.schema.ts:20-25). - Tablet/desktop: master-detail — tapping row loads detail in right pane (00-shared/05 §3).
- Analytics (proposed):
organizations.tenants.list.view,organizations.tenants.search.
S8 — Tenant detail (+ create/edit reuse)
| Attribute | Value |
|---|---|
| Route | /admin/tenants/:id |
| Purpose | Platform view of one tenant: identity, subscription, status actions, delete |
| Entry | Tenants list row; Exit: back (pane) |
| Permission | isPlatformAdmin |
Content: same identity/subscription cards as S1 (id-driven), plus status switcher (PATCH :id status — update-organization.dto.ts:130-133), plan/status edit, typed-confirm delete (organizations.service.ts:156-159), link-outs to Settings/Branding/Flags (reusing S3/S4/S5 with :id).
- Tenant create (
/admin/tenants/new): full-screen form =CreateOrganizationDtofields (create-organization.dto.ts:57-112) — name required, optional slug/domain/contact/address/timezone/currency/plan/branding/metadata; success → detail with slug handoff banner. - States/analytics: as S7 +
organizations.tenants.create.success,organizations.tenants.delete.confirmed.
Screen × data × analytics matrix
| Screen | Primary endpoint | Load pattern | Key analytics (proposed) |
|---|---|---|---|
| S1 Overview | GET /organizations/:id | cache+revalidate (TTL 5 min, volatile) | organizations.overview.view |
| S2 Edit | GET + PATCH /organizations/:id | no client cache (form) | organizations.edit.save.* |
| S3 Branding | GET + PATCH /organizations/:id (+ storage upload (planned)) | no client cache | organizations.branding.save |
| S4 Settings | GET :id/settings + PATCH :id/settings | no client cache | organizations.settings.save |
| S5 Flags | GET :id/feature-flags + GET /feature-flags + PATCH :id/feature-flags | 5-min cache | organizations.flags.toggle.* |
| S6 Members | GET/POST /rbac/members, users endpoints | 5-min cache | organizations.members.invite |
| S7 Tenants | GET /organizations (page/limit/sort/q) | paginated cache sl:{module}:{query} | organizations.tenants.list.view |
| S8 Tenant detail | GET/PATCH/DELETE /organizations/:id | no client cache | organizations.tenants.delete.confirmed |
06 — Screen Specifications (Organizations Module)
- 0. Global rules for this module
- S1 — Org Overview
- S2 — Org Edit
- S3 — Branding
- S4 — Settings tabs
- S5 — Feature flags
- S6 — Invite admin (members)
- S7 — Tenants list (platform)
- S8 — Tenant detail (+ create/edit)
Per-screen build spec. Tokens (T = 00-shared/02), components (C = 00-shared/03), motion (M = 00-shared/08 §4-5), a11y (A = 00-shared/09), mapping (D = 00-shared/04). Applies to all 8 screens of the module.
(proposed)= client-side validation/behavior.
0. Global rules for this module
- No FAB except S6 (Invite admin). Toolbar actions carry all primary actions.
- Forms >3 fields are full-screen pages (00-shared/05 §5); dialogs only for confirms/guards.
- All mutation buttons: 48 dp height, loading spinner replaces label, anti-double-submit (M §6).
- Light/dark parity, tokens only, no literal colors (
D §7). - Safe areas: bottom nav/insets respected on phone; keyboard avoidance via
Scaffold.resizeToAvoidBottomInset+AppScrollableFormwrapper. - Typography: one
headlineMediumscreen title per screen (A §11); sectionstitleMedium. - Analytics
(proposed)per 05.
S1 — Org Overview
Header: AppBar (T: 64, surface, titleLarge) — title "Organization", actions: Edit (IconButton + tooltip "Edit organization", shown iff organization.update), Branding (IconButton + tooltip). Hero: AppAvatar logo 64 (fallback initials from name, C §C), name headlineMedium, chips row: status AppBadge (primaryContainer when active, tertiary when onboarding, error when suspended — always icon+label, A §9), plan AppBadge (filled for premium/enterprise, tonal for free/basic).
Onboarding banner: AppBanner severity info, dismissible=false while status === onboarding (organization.schema.ts:122-127): "Finish setting up — configure academic year, attendance rules and branding." → action "Open settings".
Sections (cards): AppCard flat e-0, padding 16, gap 12, section gap 24 (T §3). Each section: AppSectionHeader (titleMedium) + AppInfoRow rows (label bodyMedium onSurfaceVariant, value bodyMedium onSurface). Rows: Identity (name/slug/domain — slug/domain in mono font, T §2), Contact (email/phone/website — email as TextButton link), Address (5 rows), Localization (timezone/currency), Academic year (formatted via Intl locale — e.g., "2026-04-01 → 2027-03-31", month "April"), Subscription (plan + status badges), shortcuts: Settings (AppListTile trailing chevron → S4), Feature flags (AppListTile trailing chevron + count badge → S5), Members (AppListTile → S6, rbac.member.read).
States:
- Loading: header skeleton (circle 64 + lines) + 4 card skeletons (
AppSkeleton list, shimmerm-slow, reduced-motion static, A §8). - Error:
AppErrorState(code, message, onRetry); 404 → copy "Organization not found" + reload action; 5xx → generic + requestId hint (C §A). - Offline: cached org +
AppOfflineBanner; stale badge "Updated {time}". - Empty (no data anomaly): treated as 404.
Transitions: screen enter slide-forward m-base; hero logo fade-rise m-slow; skeleton→content cross-fade m-slow (M §4).
Responsive: phone 1-col; tablet ≥600 dp 2-col info grid; desktop ≥1200 dp 3-col grid + breadcrumb "Organization".
A11y: chips read "Status: active", "Plan: premium" (never color-only); logo Semantics("Logo of {name}"); all rows focusable buttons with labels.
Micro-interactions: row tap ripple m-fast; badge scale-pop on status change (M §5).
Analytics: organizations.overview.view, organizations.overview.edit.tap, organizations.overview.branding.tap.
S2 — Org Edit
Header: AppBar "Edit organization", leading back (pop with unsaved guard), no other actions. Body: scrollable form groups (labels bodyMedium; fields gap 16, T §3). Group 1 Identity: name (required), slug (mono, helper "Used for tenant registration; changing it affects future sign-ups" — organizations.service.ts:107-113), domain (mono, helper "Must be unique across all institutions"). Group 2 Contact: email (emailAddress keyboard, @IsEmail server — update-organization.dto.ts:83-86), phone (phone keyboard), website (url keyboard). Group 3 Address: street, city, state, country, zip (all IsString — :89-94). Group 4 Localization: timezone (readonly field → searchable bottom sheet, AppSearchBar + list, returns IANA name; server default 'UTC' — organization.schema.ts:69-70), currency (sheet: code + symbol, default USD — :72-73). Group 5 Academic year: startDate/endDate (AppDatePicker, min < max validation (proposed)), month (1–12 number (proposed)). Group 6 Subscription: plan AppDropdown (free/basic/premium/enterprise — :6-11), status AppDropdown (active/inactive/suspended/trial — :13-18) — read-only when user lacks billing perms (proposed), edit disabled + lock icon.
Primary CTA: bottom-pinned "Save changes" AppButton filled fullWidth; loading spinner; disabled until dirty. Secondary: "Cancel" (text) → unsaved guard.
Dialogs: unsaved-changes (title "Discard changes?", actions Discard / Keep editing); slug change confirm (warning copy above). Snackbar: "Changes saved" (<3 s, success icon); error rollback snackbar (≥4 s).
Validation (client, (proposed) unless server-tested): name non-empty (server: @IsString only — update-organization.dto.ts:62-65); slug lowercase alnum+hyphen regex ^[a-z0-9]+(?:-[a-z0-9]+)*$ matching slugify output (organizations.service.ts:161-169); domain lowercase FQDN; email emailAddress; zip/phone free text. Server re-validates: 409 → inline on slug/domain fields (scroll-to-field), 400 → per-field details (envelope details[], http-exception.filter.ts:103-107).
Keyboard/touch: TextInputAction.next between fields, done on last; autofill hints (name, email, address-*, postal-code, tel) per A §10; keyboard avoidance; tablet/desktop 2-col field grid (label-above layout); Enter submits from text fields (M §2).
A11y: focus moved to first invalid field on failed submit (A §10); errors announced.
Animations: section entrance stagger m-entrance 40 ms; error highlight shake-free (color+icon only).
Analytics: organizations.edit.save.success|error, organizations.edit.slug.conflict.
S3 — Branding
Header: AppBar "Branding". Body: two-column on tablet/desktop (editor | live preview).
Editor column: Primary color (AppColorPicker — swatch grid + hex AppTextField [0-9A-Fa-f]{6}, (proposed) widget), Secondary color (same), Logo upload (AppAttachmentTile "school_logo.png · uploading 40%" states: idle/uploading/success/error+retry; upload → storage provider (planned), sets logoFileId/branding.logo — organization.schema.ts:114-120), Favicon upload (optional, same tile).
Preview panel: AppCard e-2 simulating: app bar tinted with primaryColor, button samples (filled/tonal), text swatches, contrast badge computing WCAG ratio vs white/black ((proposed) — warn <4.5:1, A §2), logo render. Branding override model: org primary overrides theme seed; all other roles derive (02_Design_Tokens.md §1).
Primary CTA: "Save branding" → PATCH :id {branding} (update-organization.dto.ts:125-128; server: unvalidated Record<string,unknown> — client controls shape).
States: preview updates instantly on change (m-fast color transitions); save spinner; upload error inline; offline: colors editable locally, upload blocked, save blocked with banner.
A11y: hex fields label "Primary color hex"; picker swatches have tooltip + name; contrast warning announced; color-blind: also shows hex text (A §9).
Analytics: organizations.branding.preview, organizations.branding.save.success|error.
S4 — Settings tabs
Header: AppBar "Organization settings" + global actions: "Save all" (primary, enabled iff any tab dirty). Below: AppTabs (General | Attendance | Academic | Grading | Notification | Theme), TabBarView keep-alive (00-shared/03 AppTabs), indicator primary (D §2).
Attendance tab (embedded settings — update-organization-settings.dto.ts:8-12): gracePeriod (number, suffix "min", (proposed) 0–300), lateThreshold (number, min), halfDayThreshold (number, min), workingDays (multi-select day chips AppChips Sun–Sat, values 0–6, (proposed) ≥1 required), helper "Determines how attendance is counted as late / half-day".
Academic tab: gradingScale (text, (proposed) presets A+/A/B+…, free text allowed), passingPercentage (number 0–100).
Theme tab: primaryColor/secondaryColor (hex fields — mirrors branding; saved into settings.theme).
General / Grading / Notification tabs: read-only reference lists from the standalone settings collection (GET /settings?group=…, settings.controller.ts:24-29) — "managed by module settings" copy; upsert via PUT /settings/PUT /settings/bulk is reserved for module-level config screens (cross-module).
Save model (critical): "Save all" submits the full merged settings object {attendance, academic, theme} to PATCH :id/settings (full-replace — organizations.service.ts:134). Dirty tab indicator (dot on tab, secondaryContainer); per-tab revert (discard) action; after save all tabs clean + snackbar "Settings saved".
States: per-tab skeleton while loading; global save spinner; 400 → field errors on tab; 409/404 → snackbar.
Dialogs: none (forms ≤5 fields per tab; save is global). Sheets: none.
Keyboard/landscape: numeric fields number keyboard; landscape = wider 2-col; desktop = max-width 720 form column.
A11y: chips toggle state announced (Semantics(toggled:)); numeric fields announce units in helper; tab dirty dot read as "unsaved".
Animations: tab indicator m-base; dirty-dot scale-pop; save success check + snackbar (M §5).
Analytics: organizations.settings.tab.view.{tab}, organizations.settings.save.success|error.
S5 — Feature flags
Header: AppBar "Feature flags" + info IconButton (tooltip "Flags apply to this institution only"). Body: grouped list by module (from catalog GET /feature-flags; keys absent from catalog still shown from map under "Other"). Section header + row: AppListTile title = label ?? key, subtitle = description (mono key chip), trailing AppSwitch (disabled iff no feature-flags.update).
Merge logic: catalog ∪ map; enabled = map[key] ?? (catalog only? false). Toggle → optimistic flip (00-shared/06 §3.5) → full-map PATCH :id/feature-flags (organizations.service.ts:144-154) → reconcile with server map; failure → rollback + snackbar "Couldn't update — retry" (with Retry action). Row delete (standalone flag only): AppMenu → "Delete flag" → confirm dialog → DELETE /feature-flags/:key (feature-flags.controller.ts:54-58).
Empty state: AppEmptyState "No feature flags yet — configure one in the platform catalog" (only when both lists empty).
States: skeleton rows; loading toggle spinner on the row switch (brief); offline: toggles disabled + banner (no offline queue).
Section expand/collapse: AnimatedSize m-base, chevron rotate; sections default expanded.
A11y: switch label = "Enable {flag label}"; result announced via live region; mono keys read as-is.
Analytics: organizations.flags.toggle.{key}.on|off, organizations.flags.delete.
S6 — Invite admin (members)
Header: AppBar "Team & invites". FAB: AppFAB.extended "Invite admin" (heroTag: 'org-invite'), hidden on scroll-down (C §D).
Body: section "Members" — list rows (AppListTile leading AppAvatar initials, name, email, role chips, menu). Menu: "Change roles" (sheet multi-select chips from GET /rbac/roles — rbac.controller.ts:27-31; save → PATCH /rbac/members/:id :69-73), "Remove" (dialog → DELETE /rbac/members/:id :75-79).
Invite sheet (full-screen page, >3 fields): firstName, lastName, email (emailAddress), phone (optional), roles (chips, default org_admin), CTA "Send invitation". Submission: create user (users.controller.ts:36-40 — Users module contract) then POST /rbac/members (rbac.controller.ts:63-67). No email/token invite exists (OQ-2) — copy "They'll sign up at register with tenant slug" ((forward-looking) invite email).
States: list skeleton; invite spinner; 409 duplicate email inline; empty "No members yet".
Responsive: phone = page push; tablet+ = dialog (≤ 5 fields? use sheet half-height on tablet).
A11y: FAB label "Invite admin" (A §7); role chips announce selection.
Analytics: organizations.members.invite.submit, organizations.members.role.change, organizations.members.remove.
S7 — Tenants list (platform)
Header: AppBar "Tenants" + count subtitle "12 tenants" (from meta.totalItems). Toolbar row: AppSearchBar (debounce 300 ms, clear suffix), sort AppDropdown (AppMenu): Newest (-createdAt, default), Oldest, Name A–Z, Name Z–A (maps to sort param, pagination-query.dto.ts:21-24), status filter AppChips (All/Active/Onboarding/Suspended/Inactive — (proposed) filter, no server param today; client-side or future status= query).
List: ListView.builder rows 72 dp: logo 40, name (titleMedium), slug (bodySmall mono), status AppBadge + plan badge, created date (bodySmall), trailing AppMenu (View / Edit / Suspend·Activate / Delete). Infinite scroll: bottom spinner fade m-instant, "End of list" footer bodySmall (meta.hasNext — pagination-query.dto.ts:52). Pull-to-refresh bypasses cache (00-shared/06 §3.3).
States: skeleton list (first load); error AppErrorState + retry; empty AppEmptyState ("No tenants match your search"); offline last-good + banner; permission (non-platform) → 403 screen (route guard).
Tablet/desktop: master-detail — row tap loads S8 in right pane (≥840 dp, 00-shared/05 §3); hover row tint m-fast; keyboard: N new tenant, arrows navigate rows (M §2).
Dialogs: suspend/activate confirm (AppDialog with consequence copy), delete = typed confirm.
Analytics: organizations.tenants.list.view, organizations.tenants.search, organizations.tenants.filter.status.
S8 — Tenant detail (+ create/edit)
Header: AppBar back + tenant name; actions: Edit (organization.update-equivalent, platform), Status menu (Activate/Suspend), Delete (destructive icon). Body reuses S1 card set (id-driven) + status/plan switchers (dropdowns → PATCH :id status/subscriptionStatus/subscriptionPlan — update-organization.dto.ts:115-133).
Delete flow: AppDialog → typed confirm (type org name; 00-shared/05 §5) → spinner → DELETE :id (organizations.service.ts:156-159) → snackbar "Tenant deleted — data purged after 30 days" (worker tenant-purge.worker.ts:32-42) → back to list, row removed.
Create (/admin/tenants/new): full-screen form per CreateOrganizationDto (create-organization.dto.ts:57-112): name*, slug (optional, live-preview of slugify result — organizations.service.ts:38), domain, contact block, address block, timezone, currency, plan, branding (skip link), metadata (JSON field (proposed)). CTA "Create tenant" → success → tenant detail + handoff banner ("Share slug: springfield-academy — the admin registers with this slug").
States: detail skeleton; save spinners; 409 slug/domain inline; create success haptic lightImpact (M §3); error states standard.
Analytics: organizations.tenants.create.success|error, organizations.tenants.status.change, organizations.tenants.delete.confirmed.
07 — Component Library (Organizations Module)
- OrgHeader
- StatusBadge
- SettingFieldGroup
- ColorPickerField
- DayChips (workingDays)
- FlagSwitchRow
- TenantRow
- SlugPreview
- OrgSettingsForm (S4 composite)
- Component × token × Flutter mapping
Module-specific widgets. All shared
App*components referenced from 00-shared/03 are used as-is (AppSkeleton, AppCard, AppListTile, AppTextField, AppDropdown, AppSwitch, AppChips, AppDatePicker, AppDialog, AppBottomSheet, AppMenu, AppSnackbar, AppBanner, AppEmptyState, AppErrorState, AppOfflineBanner, AppSearchBar, AppFAB, AppAvatar, AppBadge, AppSectionHeader, AppInfoRow, AppProgress, AppTabs). Listed below are new components.
OrgHeader
- Purpose: hero header for S1/S8 (identity + status chips).
- Props:
name,logoUrl?,status(OrganizationStatus —organization.schema.ts:20-25),plan,subscriptionStatus,onEdit?,onBranding?. - Variants:
compact(list rows S7),hero(detail). - States: loading (skeleton), error (fallback initials), offline.
- Tokens:
surfacebg,e-1,r-lg,titleLargename,bodySmallslug; badgesprimaryContainer/tertiary/errorwith icon+label (A §9). - A11y: single combined
Semanticsfor header ("{name}, status active, plan premium"). - Flutter:
Row/CircleAvatar+Column+Wrapbadges;heroTagfor list→detail flight (M §4).
StatusBadge
- Purpose: org lifecycle chip (active/inactive/suspended/onboarding) + subscription chip.
- Props:
value,type(status|subscription|plan). - Mapping: status → icon (
check_circle/pause/block/rocket_launch) + label; plan → filled/tonal badge; never color-only. - Flutter:
Badge/Chipwrapper with icon + localized label.
SettingFieldGroup
- Purpose: labelled form group container for S2/S4 with dirty + error aggregation.
- Props:
title,subtitle?,dirty,errorCount,children. - States: dirty dot (
secondaryContainer), group error banner (errorContainer, A §2). - Flutter:
Semantics(header:)Column+AnimatedSwitcherfor dot.
ColorPickerField
- Purpose: branding/settings color input (S3/S4) —
(proposed)custom widget. - Props:
label,value(hex),presets?,onChanged,errorText. - Behaviour: swatch grid + hex text field (sync both ways); opens system color dialog on desktop; contrast helper (WCAG ratio vs onPrimary/onSurface computed live).
- Validation:
/^#?[0-9A-Fa-f]{6}$/(proposed); error "Enter a 6-digit hex color". - A11y: label + value semantics; swatches tooltips.
- Flutter:
WrapofInkWellswatches +TextFieldformatter (TextInputFormatter);showDialogcolor picker for web.
DayChips (workingDays)
- Purpose: multi-select Sun–Sat for attendance settings (S4).
- Props:
selected(Set), onChanged,disabled. - Validation: ≥1 day
(proposed)— error under the chip row. - A11y: chip
Semantics(toggled:)+ day names. - Flutter:
WrapofFilterChipwith localized weekday labels (weekday index 0=Sunday perintl).
FlagSwitchRow
- Purpose: feature-flag list row (S5) with optimistic toggle.
- Props:
key,label?,description?,module?,enabled,pending,onToggle,onDelete?. - States: idle / pending (row switch shows mini spinner, row dimmed) / error (row error tint + retry).
- Behaviour: optimistic flip → server reconcile → rollback on error with
AppSnackbarretry (00-shared/06 §3.5). - A11y: label "Enable {label}", live-region announcement of toggle result.
- Flutter:
ListTile+SwitchwithAnimatedSwitcherspinner;Dismissible-free (menu delete only).
TenantRow
- Purpose: tenants-list row (S7).
- Props:
org(name, slug, status, plan, logo, createdAt),onTap,menuitems. - States: hover tint (desktop), selected (master-detail), loading shimmer for images.
- Flutter:
ListTile72 dp +MenuAnchor;Heroavatar.
SlugPreview
- Purpose: live slug auto-derivation preview in S8 create form.
- Props:
name,slugOverride?,onAccept. - Behaviour: shows
slugifyresult (client mirror oforganizations.service.ts:161-169); "use this" button fills the slug field. - Flutter: inline
Tooltip+TextButton.
OrgSettingsForm (S4 composite)
- Purpose: the tabbed embedded-settings form with full-object save.
- Props:
attendance,academic,theme(initial values),dirtytracking per tab,onSaveFull(merged). - Rules: never saves partial; merge = spread last server state + current tab edits.
- Flutter:
TabBar+TabBarView(keep-aliveAutomaticKeepAliveClientMixin).
Component × token × Flutter mapping
| Component | Tokens | Flutter |
|---|---|---|
| OrgHeader | surface, e-1, r-lg, titleLarge | Card + Row |
| StatusBadge | primaryContainer/tertiary/error | Chip + Badge |
| SettingFieldGroup | surfaceContainerLow, outlineVariant | Container + AnimatedSwitcher |
| ColorPickerField | surfaceVariant, outline, error | TextField + Wrap |
| DayChips | secondaryContainer selected | FilterChip |
| FlagSwitchRow | surface, e-0, onSurfaceVariant | ListTile + Switch |
| TenantRow | surface, r-md, e-0 | ListTile + MenuAnchor |
| SlugPreview | primary, mono | TextButton + Text |
Golden tests: every component above at 3 sizes + dark mode (00-shared/03 §G).
08 — Form Specifications (Organizations Module)
- F1 — Create tenant (S8 create,
CreateOrganizationDto) - F2 — Edit org profile (S2,
UpdateOrganizationDto) - F3 — Branding (S3)
- F4 — Organization settings (S4,
UpdateOrganizationSettingsDto) - F5 — Register (first admin) — cross-module (Auth)
- F6 — Settings collection upsert (module config) — cross-module
- F7 — Feature flag (standalone catalog)
- Cross-form rules
Field-by-field form specs. Server rules are exact from DTOs (
create-organization.dto.ts,update-organization.dto.ts,update-organization-settings.dto.ts,register.dto.ts,update-setting.dto.ts,update-feature-flag.dto.ts). Rules marked(proposed)are client-side only — the backend imposes no min/max/length on those fields. ServerValidationPipe: whitelist + forbidNonWhitelisted + transform (main.ts:50-57).
F1 — Create tenant (S8 create, CreateOrganizationDto)
| Field | Label | Type/Keyboard | Validation (server) | Client rules (proposed) | Default |
|---|---|---|---|---|---|
| name | Institution name | text | required @IsString (create-organization.dto.ts:59-60) | required, 2–120 chars, trimmed | — |
| slug | Tenant slug | text (mono) | optional @IsString (:63-65) | ^[a-z0-9]+(?:-[a-z0-9]+)*$; live SlugPreview of auto-value | auto from name |
| domain | Domain | text (mono) | optional @IsString (:68-70) | lowercase FQDN; unique platform-wide (409) | — |
| contact.email | Contact email | @IsEmail (:16-17) | valid email | — | |
| contact.phone | Contact phone | phone | @IsString (:20-21) | 7–20 digits/+/spaces | — |
| contact.website | Website | url | @IsString (:24-25) | http(s) URL | — |
| address.* | street/city/state/country/zip | text | @IsString each (:35-54) | free text, 120 max | — |
| timezone | Timezone | sheet picker (searchable) | @IsString (:87-89) | IANA name from picker | UTC (organization.schema.ts:69-70) |
| currency | Currency | sheet picker | @IsString (:92-94) | ISO code from picker | USD (:72-73) |
| subscriptionPlan | Plan | dropdown | @IsEnum(SubscriptionPlan) (:100-102) | enum free/basic/premium/enterprise | free |
| branding | Branding | (link to S3) | @IsObject (:106-107) | deferred | — |
| metadata | Metadata | JSON textarea (proposed) | @IsObject (:110-111) | valid JSON object | — |
Submission: CTA "Create tenant"; on success → tenant detail + slug handoff banner. Duplicate email? N/A (contact email not unique). Slug/domain conflicts → 409 inline.
F2 — Edit org profile (S2, UpdateOrganizationDto)
| Field | Label | Rules | Notes |
|---|---|---|---|
| name | Institution name | @IsString optional (update-organization.dto.ts:63-65) | 2–120 (proposed) |
| slug | Tenant slug | optional string (:68-70) | changing = identity change → confirm dialog; conflict 409 (organizations.service.ts:107-113) |
| domain | Domain | optional string (:73-75) | conflict 409 if owned by other org (:115-122) |
| logoFileId | Logo | optional string (:79-80) | set via upload flow (storage (planned)) |
| contact.* | Contact | optional @IsEmail/@IsString (:85-87) | email validated |
| address.* | Address | optional strings (:89-94) | free text |
| timezone / currency | Localization | optional strings (:97-104) | pickers |
| academicYear.startDate / endDate | Academic year | optional Date (:109-111) | IsDate via implicit transform; start < end (proposed) |
| academicYear.month | Year start month | optional number (:112) | 1–12 (proposed) |
| subscriptionPlan / subscriptionStatus | Subscription | enums (:115-123) | dropdowns; read-only for non-billing (proposed) |
| branding | Branding | @IsObject (:126-128) | see F3 |
| status | Status | @IsEnum(OrganizationStatus) (:130-133) | platform surface only (S8) |
| metadata | Metadata | @IsObject (:137-138) | JSON |
Submission: "Save changes" → PATCH :id; partial update allowed (PATCH semantics, $set dto — organizations.service.ts:124); version increments (organizations.repository.ts:55).
F3 — Branding (S3)
| Field | Label | Rules | Notes |
|---|---|---|---|
| primaryColor | Primary color | hex /^#?[0-9A-Fa-f]{6}$/ (proposed) | server: unvalidated Record<string,unknown> (update-organization.dto.ts:126-128); theme override per 02_Design_Tokens.md §1 |
| secondaryColor | Secondary color | hex (proposed) | same |
| logo | Logo | file upload → id (storage (planned)) | organization.schema.ts:118; preview tile |
| favicon | Favicon | file upload (optional) | :119 |
Submission: whole branding object → PATCH :id. Contrast check ≥4.5:1 vs white for primary as CTA bg (proposed) warning.
F4 — Organization settings (S4, UpdateOrganizationSettingsDto)
| Field | Label | Type | Server rule | Client (proposed) | Default |
|---|---|---|---|---|---|
| attendance.gracePeriod | Grace period (min) | number | @IsObject container only (update-organization-settings.dto.ts:8-12) | 0–300, integer | — |
| attendance.lateThreshold | Late threshold (min) | number | same | 0–300 | — |
| attendance.halfDayThreshold | Half-day threshold (min) | number | same | 0–300; > lateThreshold hint | — |
| attendance.workingDays | Working days | day chips (int 0–6) | number[] | ≥1 selected; values 0–6 | — |
| academic.gradingScale | Grading scale | text | :18-21 | presets + free text | — |
| academic.passingPercentage | Passing % | number | same | 0–100 | — |
| theme.primaryColor / secondaryColor | Theme colors | hex | :26-29 | same as F3 | — |
Submission — CRITICAL full-replace: PATCH :id/settings sends the complete merged object {attendance, academic, theme} — partial bodies wipe sibling groups (organizations.service.ts:134). Dirty tracking per tab; one global save.
F5 — Register (first admin) — cross-module (Auth)
| Field | Label | Type | Server rule (exact) |
|---|---|---|---|
| firstName | First name | text | @IsString (register.dto.ts:6-8) |
| lastName | Last name | text | @IsString (:9-11) |
@IsEmail (:13-15); 409 if used (auth.service.ts:56-58) | |||
| password | Password | password | @IsString + @MinLength(8) (:17-20); Argon2id hashed (auth.service.ts:71) |
| phone | Phone | phone | optional @IsString (:22-25) |
| tenantId | Tenant slug | text (mono) | @IsString required (:27-29); no org-existence check (OQ-2) |
Submission: rate 5/min (auth.controller.ts:31); success returns tokens directly (no redirect); autofill hints name, email, new-password (A §10).
F6 — Settings collection upsert (module config) — cross-module
UpdateSettingDto: key (required), value (required, any), group enum academic/attendance/grading/notification/theme/general (setting.schema.ts:7-14), label, description. PUT /settings upsert (settings.controller.ts:37-41); bulk via PUT /settings/bulk (:43-47). Used by S4 reference tabs (read) — write surface owned by feature modules (cross-module).
F7 — Feature flag (standalone catalog)
UpdateFeatureFlagDto: key (required string), enabled (required boolean — update-feature-flag.dto.ts:9-11), label/description/module (optional). PUT /feature-flags upsert (feature-flags.controller.ts:42-46), bulk :48-52, delete :54-58. Upsert only writes enabled + label (feature-flag.repository.ts:32-43).
Cross-form rules
- Validation timing: on submit + after first error on change; error clears on edit (00-shared/03 AppTextField).
- Submit loading: spinner in CTA, anti-double-submit; success haptic
lightImpact, errormediumImpact(M §3). - Offline: forms render from last-good cache (S2/S4), submit disabled + banner; drafts kept in-memory (no offline queue — 00-shared/07 §10).
- Conflict UX: 409 → inline on the specific field (slug/domain/email), scroll-to-field, focus.
- Server errors: 400 details mapped per field (
http-exception.filter.ts:103-107); unknown fields → "Unexpected field" (forbidNonWhitelisted).
09 — User Behaviour (Organizations Module)
- 1. Behavioural expectations
- 2. Common mistakes + corrections
- 3. Fast paths
- 4. Power users
- 5. New users
- 6. Expert users
- 7. A11y users
- 8. Interrupted sessions & retries
- 9. Abandonment
- 10. Frustrations + UX solutions
Expected behaviours, mistakes, fast paths, persona-class needs, interrupted sessions, retries, abandonment, frustrations + UX solutions. Derived from code semantics.
1. Behavioural expectations
| Context | Expected behaviour | Why (source) |
|---|---|---|
| Save profile → overview | Overview reflects change instantly | PATCH returns updated doc (organizations.service.ts:124-126); client reconciles |
| Toggle a feature flag | Immediate flip, rollback on failure | full-replace map API (organizations.service.ts:144-154) → optimistic per 00-shared/06 §3.5 |
| Change working days | Affects future attendance counts only; no retro warning | settings replace semantics (:129-137) |
| Delete tenant | Row gone immediately; understood as reversible 30 days | soft delete (organizations.repository.ts:60-66) + purge worker (tenant-purge.worker.ts:32-42) |
| Create tenant with name "St. Mary's School" | Slug auto = st-marys-school | slugify (organizations.service.ts:161-169) — user expects predictability; show preview (F1 SlugPreview) |
| Change plan/status | Banner/status updates everywhere | PATCH $set (:124) |
2. Common mistakes + corrections
| Mistake | UX solution |
|---|---|
| Mistyped slug/domain → 409 | Inline field error + suggested alternatives (name-derived slug via SlugPreview) |
| Editing slug late (admins already registered with it) | Pre-submit confirm dialog: "Existing admins keep working; new registrations must use the new slug" |
| Saving one settings tab → siblings wiped | Full-object save model (S4); dirty-dot per tab; "Save all" only; never per-tab save |
| Toggling flags fast (double-tap) | Anti-double-submit on toggle; final state from server reconcile |
| Register with wrong tenant slug | (forward-looking) pre-submit hint + "tenant not found" guidance on login failure; server validation (planned) OQ-2 |
| Deleting the wrong tenant | Typed confirm (type org name) — 00-shared/05 §5 |
| Setting timezone/currency via free text | Searchable picker sheets; free text still allowed server-side (@IsString) but discouraged |
| Half-day threshold < late threshold | Cross-field validation hint (proposed) |
3. Fast paths
- Desktop:
Nnew tenant (S7);Ctrl+Kglobal search;Entersubmits forms (M §2). - Overview → any edit surface in 1 tap (toolbar actions); settings deep link from onboarding banner.
- Toggle rows don't require save (flags are immediate); settings require explicit "Save all" (destructive-ish).
- Tenants list keeps sort/search while paging (debounced
q,pagination-query.dto.ts:21-29).
4. Power users
Platform Super Admins batch-provision: support multi-select on list (proposed) and keyboard-driven status change; expect meta.totalItems accuracy after each delete (refetch meta).
5. New users
- First-run: onboarding banner drives them to S4 (academic year + attendance) before other modules.
- Invited admin: registration pre-fill from invite context
(forward-looking); slug handoff copy in S8 create success.
6. Expert users
- Keyboard-only desktop flows; expect status change without dialog navigation friction (menu → confirm → done ≤3 steps).
- Expect full
settingsobject integrity — Surface "last saved {time}" per tab.
7. A11y users
- VoiceOver/TalkBack: badge states, toggle results, error focus (A §5, §10); color picker hex fallback; never color-only status.
- Keyboard: focus order = toolbar → search → filters → list; ESC closes menus/sheets; typed confirm dialog traps focus (A §6).
8. Interrupted sessions & retries
| Interruption | Behaviour |
|---|---|
| App kill mid-form | Form drafts lost (in-memory only) — guidance: save early; (proposed) autosave to prefs |
| Network loss mid-save | Pending spinner → error snackbar "Couldn't save — retry" (idempotent PATCH; safe to retry) |
| 401 mid-save | Silent refresh → replay; refresh failure → session-expiry overlay, draft preserved (00-shared/06 §3.6) |
| Rate limited (429) | "Too many requests — retry in Ns", backoff, no auto-retry (07_API_Conventions.md §4) |
| 5xx | Generic + requestId; retry offered (never surface internals — http-exception.filter.ts:60-64) |
9. Abandonment
- Cancel edit → unsaved-guard dialog (Keep editing / Discard); discard = back, no server call (nothing sent until submit).
- Tenants list pagination resets on filter change — fine (page 1); note:
pagebeyondtotalPagesreturns empty list (meta still sayshasNext:false—pagination-query.dto.ts:46-53) → show "End of results".
10. Frustrations + UX solutions
| Frustration | Root cause | Solution |
|---|---|---|
| "Why is my org missing?" | OQ-1 — no self-org endpoint | (planned) /organizations/me; stopgap lookup documented; never guess |
| "My settings disappeared" | full-replace $set {settings} (organizations.service.ts:134) | full-object save model + wipe-prevention QA (S4) |
| "Which settings apply?" | dual surfaces: embedded vs settings collection (OQ-6) | visual separation + copy on reference tabs |
| "I see other schools!" | unscoped list + unguarded controller (OQ-4) | platform-only gating client-side; server scope (planned) |
| "Flag toggle didn't stick" | full-map replace races (last-write-wins) | optimistic + reconcile + pull-to-refresh; WS push (forward-looking) |
| "Delete isn't permanent?" | 30-day purge retention | explicit snackbar/copy "purged after 30 days"; no restore endpoint (ops-only) |
10 — Interaction Specification (Organizations Module)
- 1. Touch gestures (phone/tablet)
- 2. Desktop/web (mouse + keyboard)
- 3. Haptics (mobile)
- 4. Motion language (module)
- 5. Micro-interaction catalogue (module additions)
- 6. Frustration controls
Module specialization of 00-shared/08. Covers every input modality for the 8 screens. Motion tokens M = 00-shared/08 §4-5; haptics §3.
1. Touch gestures (phone/tablet)
| Gesture | Behaviour | Where |
|---|---|---|
| Tap | Primary action, ripple m-fast (150 ms) | Rows, chips, switches, CTAs everywhere |
| Long-press | Context menu (AppMenu) | Tenant row (S7), member row (S6), flag row (S5, delete) |
| Double-tap | Nothing module-specific (no media) | — |
| Swipe left/right | None — destructive actions are menu+confirm only (delete is typed-confirm) | S7/S8 |
| Pull down | Refresh (bypasses cache) | S1, S4 (tab content), S5, S6, S7 |
| Pinch | None | — |
| Hardware back / gesture | Pop to previous; from S2/S3/S4 with unsaved changes → unsaved-guard dialog; root tab → shell | S2/S3/S4 |
| Edge swipe | Drawer | Shell |
| Tap outside sheet | Dismiss timezone/currency pickers (returns value if selected) | S2 |
2. Desktop/web (mouse + keyboard)
| Input | Behaviour |
|---|---|
| Hover | m-fast tint + tooltip ≥500 ms on all icon buttons (Edit, Branding, Info, row menus) |
| Right-click | Context menu = long-press menu (S7/S6/S5 rows) |
| Wheel | Scroll lists; Ctrl+wheel zoom on logo preview (S3) |
| ESC | Closes menu/sheet/dialog/typed-confirm; cancels search (S7 clears q) |
| Tab/Shift+Tab | Logical focus: toolbar → search → filter chips → list → pagination; visible primary ring 2dp (A §6) |
| Enter | Submit focused form (S2/S4/F1); in dialog: confirms |
| Ctrl/Cmd+Enter | Submit settings "Save all" from text areas |
N | New tenant (S7) — 00-shared/05 §7 |
| Ctrl+K | Global search (shell) |
| Arrows | Navigate menus, dropdowns, day chips (S4) |
| Space | Toggle focused switch/chip |
3. Haptics (mobile)
| Event | Haptic |
|---|---|
| Save success (S2/S3/S4/F1) | lightImpact |
| Save/toggle error + rollback | mediumImpact |
| Long-press menu open | selectionClick |
| Pull-to-refresh completion | lightImpact |
| Typed-confirm (delete) submit | heavyImpact |
| Toggle flip success | lightImpact (single, not per toggle burst) |
Off when system haptics disabled; none on web/desktop (M §3).
4. Motion language (module)
| Transition | Token | Detail |
|---|---|---|
| Screen enter/exit | m-base 250 ms | slide-forward; forms slide-reverse on back |
| Skeleton→content | m-slow 400 ms cross-fade | no pop |
| Hero: list row → tenant detail | m-slow | logo avatar flight (S7→S8) |
| Tab switch | m-base | indicator slide |
| Toggle switch | m-fast | standard M3 switch; row dim + spinner swap via AnimatedSwitcher |
| Dirty dot appear | m-fast scale-pop | S4 tabs |
| Section expand (flags by module) | m-base | AnimatedSize, chevron rotate |
| Color preview change | m-fast | AnimatedContainer on swatch/preview panel |
| Dialog/sheet | m-base | scale+fade in, slide-down dismiss |
| Snackbar | m-base | slide-up, queue single (00-shared/03 §A) |
| Infinite-scroll loader | m-instant fade | S7 |
| Stagger | m-entrance 300 ms, 40 ms stagger | S2/S8 form sections (first view only) |
Reduced motion: all of the above collapse to fades ≤ m-instant; no stagger, no shimmer, no hero flight, no parallax (A §8, M §4).
5. Micro-interaction catalogue (module additions)
| Interaction | Detail |
|---|---|
| Row press | Ink ripple + 2% surface darken within 100 ms (M §6) |
| Status badge change | scale-pop + icon morph (AnimatedSwitcher) |
| SlugPreview | mono text updates per keystroke (debounce 150 ms) with "use this" appearing |
| Save success | brief check icon in CTA (400 ms) → snackbar |
| Typed-confirm | destructive button disabled until typed text equals org name; heavyImpact on enable |
6. Frustration controls
- Every interactive element responds ≤100 ms; all network actions show pending state.
- Anti-double-submit on every CTA and every flag toggle.
- 429 → countdown text, no auto-retry (07_API_Conventions.md §4).
- No full-screen flashes; ripple only (M §6).
11 — Design System Mapping (Organizations Module)
- 1. Module component → M3/Flutter/tokens
- 2. Screen-level mapping (tokens)
- 3. Typography mapping (per element)
- 4. Spacing (4 dp grid, 00-shared/02 §3)
- 5. Color roles in module
- 6. Iconography
- 7. Motion mapping (module)
- 8. Layout tokens
- 9. Theming rules (enforced, D §7)
Module extension of 00-shared/04. Maps module components + screens to M3, tokens, spacing, type, elevation, radius, color, icon, motion.
1. Module component → M3/Flutter/tokens
| Component | M3 role | Flutter | Tokens (02) | Elevation | Radius |
|---|---|---|---|---|---|
| OrgHeader | Card | Card + Row | surface, onSurface | e-1 | r-lg 12 |
| StatusBadge | Badge/Chip | Badge/Chip | primaryContainer/tertiary/error + icon | e-0 | r-full |
| SettingFieldGroup | Grouped field | Column + AnimatedSwitcher | surfaceContainerLow, outlineVariant | e-0 | r-md 8 |
| ColorPickerField | Input + swatches | TextField + Wrap | surfaceVariant fill, outline border, primary focus | e-1 | r-md |
| DayChips | Filter chip | FilterChip | selected secondaryContainer / idle surfaceVariant | e-0 | r-sm 4 |
| FlagSwitchRow | List row | ListTile + Switch | surface, onSurfaceVariant trailing | e-0 | — |
| TenantRow | List row | ListTile 72 + MenuAnchor | surface, primary selected | e-0 | r-md |
| SlugPreview | Inline action | TextButton + Text | primary, mono | e-0 | — |
| OrgSettingsForm | Tabbed form | TabBar + TabBarView | indicator primary | — | — |
2. Screen-level mapping (tokens)
| Screen | Background | Cards | Primary CTA | Icons (Material Symbols outlined) |
|---|---|---|---|---|
| S1 Overview | surface | AppCard flat surfaceContainerLow | toolbar Edit IconButton | edit, palette, settings, flag, group, chevron_right |
| S2 Edit | surface | field groups surfaceContainerLow | FilledButton "Save changes" | arrow_back, schedule, calendar_month |
| S3 Branding | surface | preview AppCard e-2 | FilledButton "Save branding" | palette, upload_file, contrast |
| S4 Settings | surface | per-tab forms | FilledButton "Save all" | event_busy (attendance), school, format_paint |
| S5 Flags | surface | grouped rows | switches only | flag, expand_more, delete |
| S6 Members | surface | rows + invite page | FAB AppFAB.extended | person_add, more_vert |
| S7 Tenants | surface | TenantRow | (none; row menus) | search, filter_list, more_vert, business |
| S8 Detail/Create | surface | S1 cards + status card | "Create tenant"/"Save" | arrow_back, edit, block, delete, rocket_launch |
3. Typography mapping (per element)
| Element | Token | Widget |
|---|---|---|
| Screen titles | headlineMedium 28/400/36 | S1/S8 titles |
| Card/section headers | titleMedium 16/500/24 | AppSectionHeader |
| List row titles | titleMedium | TenantRow, member rows |
| Slug/domain/ids | mono 14/400/20 | slug chips, slug fields |
| Labels/helper | bodyMedium 14/400/20 | form labels, chip labels |
| Timestamps/captions | bodySmall 12/400/16 | "Created 2026-08-01" |
| Badges | labelMedium 12/500/16 | StatusBadge |
| Buttons/tabs | labelLarge 14/500/20 | CTAs, tabs |
| Empty state | displaySmall 36/400/44 | S5/S7 empty states |
4. Spacing (4 dp grid, 00-shared/02 §3)
Screen padding 16 (phone) / 24 (tablet+); card padding 16; card gap 12; section gap 24; form field gap 16; list rows ≥56 (S7 rows 72); toolbar 64; FAB 56.
5. Color roles in module
| Role | Usage |
|---|---|
primary | CTAs, focus, selected tab, links, selected nav |
secondary | success snackbars, "use this slug" |
tertiary | onboarding banner, warnings (working-day warnings) |
error | destructive (delete), validation, error states |
primaryContainer | active status badge, primaryContainer tonal CTA |
surfaceVariant | input fills, idle chips |
Tenant branding override: org branding.primaryColor/settings.theme.primaryColor → ColorScheme.fromSeed re-derivation (D §7.5); live preview in S3 (proposed wiring). |
6. Iconography
Material Symbols outlined 24 dp default; status icons filled (check_circle, pause_circle, block, rocket_launch); empty-state icons 32–40; every icon has tooltip + Semantics.label (D §4, A §7).
7. Motion mapping (module)
Page transition m-base (FadeUpwards Android / Cupertino iOS — D §5); dialog/sheet m-base; hero m-slow; ripple m-fast; stagger m-entrance; skeleton shimmer m-slow loop (reduced-motion static); snackbar m-base.
8. Layout tokens
Master-detail breakpoint 840 dp (S7↔S8); 2-col info grid ≥600 dp (S1); form max-width 720 on desktop (S2/S4); preview column ≥840 dp (S3).
9. Theming rules (enforced, D §7)
- No literal colors — tokens only.
- No literal spacing/durations —
AppSpacing/AppMotion. - Dark mode first-class:
surface#141414, cardssurfaceContainerLow#1D1B1B, parity QA per 10_QA_Baseline.md §6. - Branding override only touches
primaryseed; rest derives algorithmically (02 §1).
12 — API Mapping (Organizations Module)
- E1 — Create organization
- E2 — List organizations (tenants)
- E3 — Get organization
- E4 — Update organization
- E5 — Delete organization (soft)
- E6 — Get embedded settings
- E7 — Update embedded settings
- E8 — Get org feature-flag map
- E9 — Update org feature-flag map
- E10 — Standalone settings collection
- E11 — Standalone feature flags
- E12 — Register (first admin)
- E13 — RBAC (invite admin)
- Client contract summary (all screens)
Exact endpoints per screen. Wire contract per 00-shared/07: base
/api/v1, Bearer JWT, success{success:true,message:"OK",data,meta?,timestamp,requestId}, error envelope with codes. Only shapes in code are used.(planned)/(forward-looking)marked.
E1 — Create organization
| Endpoint | POST /api/v1/organizations (organizations.controller.ts:23-27) |
| Guard | none in code today (OQ-4); client restricts to platform admin; JWT+RBAC guard (planned) |
| Request | CreateOrganizationDto (create-organization.dto.ts:57-112) — name required; slug/domain/contact/address/timezone/currency/plan/branding/metadata optional |
| Response | 201 envelope, data = org doc (status forced onboarding — organizations.service.ts:55); no meta |
| Errors | 400 VALIDATION_ERROR; 409 DUPLICATE_RESOURCE (slug/domain, :40-50); 429 RATE_LIMITED; 5xx |
| Client | S8 create form; loading spinner; on success → tenant detail + slug handoff |
| Cache | none (write) |
| Realtime | event OrganizationCreated → in-app queue job org-created-notification (event-queue-map.ts:13) — notification to tenant admins (forward-looking) UI |
E2 — List organizations (tenants)
| Endpoint | GET /api/v1/organizations?page&limit&sort&q (organizations.controller.ts:29-33) |
| Params | page ≥1 default 1; limit 1–100 default 20; sort (-field desc); q → $or regex on name/slug case-insensitive (organizations.service.ts:78-83); default sort -createdAt (:85-87) |
| Response | paginated: data array + meta {page,limit,totalItems,totalPages,hasNext,hasPrevious} (pagination-query.dto.ts:32-54) |
| Errors | 400 (bad params); 429; 5xx |
| Client | S7 tenants list; search debounce 300 ms; infinite scroll on hasNext; pull-to-refresh bypasses cache |
| Cache | client paginated cache sl:{tenantId}:orgs:{query} TTL 5 min (volatile); server Redis cache n/a (no cache key in code for this route) |
| Security note | repo is not tenant-scoped (organizations.repository.ts:15-19) — platform-only gating mandatory (OQ-4) |
E3 — Get organization
| Endpoint | GET /api/v1/organizations/:id (organizations.controller.ts:35-40) |
| Response | 200 envelope, data = org doc |
| Errors | 400 VALIDATION_ERROR (bad ObjectId → CastError mapping http-exception.filter.ts:47-55); 404 RESOURCE_NOT_FOUND (organizations.service.ts:72); cross-tenant id → 404 (no leak) |
| Client | S1/S8 detail; :id resolution: platform = from route; self = OQ-1 ((planned) /organizations/me; stopgap GET /organizations?q=<tenantId slug>) |
| Cache | no client cache (detail views, 00-shared/06 §3.3); stale-while-revalidate OK |
E4 — Update organization
| Endpoint | PATCH /api/v1/organizations/:id (organizations.controller.ts:42-47) |
| Request | UpdateOrganizationDto (update-organization.dto.ts:61-139) — partial; $set merge, version +1 (organizations.repository.ts:49-58) |
| Response | 200 updated doc |
| Errors | 404; 409 slug/domain conflict (organizations.service.ts:107-122); 400 enums invalid |
| Client | S2 form, S8 status/plan switchers, S3 branding save; conflict → inline field error |
| Realtime | OrganizationUpdated interface exists (organization-created.event.ts:10-16) but not emitted — no audit signal (OQ-3) |
E5 — Delete organization (soft)
| Endpoint | DELETE /api/v1/organizations/:id (organizations.controller.ts:49-54) |
| Behaviour | soft delete: isDeleted:true, deletedAt, deletedBy + version (organizations.repository.ts:60-66); returns 200, data = null (void handler) |
| Errors | 404 (organizations.service.ts:158) |
| Client | S8 typed-confirm dialog; snackbar "purged after 30 days"; row removed |
| Purge | worker TENANT_PURGE (tenant-purge.worker.ts:17-47): deletes all models' isDeleted && deletedAt < now−30d, idempotent; enqueue from module (planned) OQ-5; GDPR erasure for users (:27-30,49-56) |
E6 — Get embedded settings
| Endpoint | GET /api/v1/organizations/:id/settings (organizations.controller.ts:56-62) |
| Response | data = org.settings ?? {} — object or {} (never null) |
| Errors | 404 (via findById) |
| Client | S4 form load |
E7 — Update embedded settings
| Endpoint | PATCH /api/v1/organizations/:id/settings (organizations.controller.ts:64-72) |
| Request | UpdateOrganizationSettingsDto (update-organization-settings.dto.ts:4-30) |
| Response | 200 updated org doc (settings replaced wholesale — organizations.service.ts:134) |
| Errors | 404; 400 (workingDays elements must be numbers) |
| Client | S4 "Save all" — always full merged object; wipe risk if partial (OQ-6) |
E8 — Get org feature-flag map
| Endpoint | GET /api/v1/organizations/:id/feature-flags (organizations.controller.ts:74-79) |
| Response | data = Record<string,boolean> (metadata.featureFlags ?? {} — organizations.service.ts:139-142) |
| Client | S5 merge source |
E9 — Update org feature-flag map
| Endpoint | PATCH /api/v1/organizations/:id/feature-flags (organizations.controller.ts:81-89) |
| Request | raw body Record<string,boolean> — no DTO, no whitelist; non-boolean → 400 VALIDATION_ERROR (type check on cast, transform:false for raw body — actually plain body bypasses DTO: values not validated server-side except by schema typing; see OQ note) |
| Response | 200 updated map (full replace 'metadata.featureFlags': flags — organizations.service.ts:144-154) |
| Client | S5 optimistic toggle → full map submit; rollback on error |
E10 — Standalone settings collection
| Endpoints | GET /api/v1/settings?group= (settings.controller.ts:24-29); GET /settings/:key (:31-35); PUT /settings upsert (:37-41); PUT /settings/bulk (:43-47); DELETE /settings/:key (:49-53) — all JwtAuthGuard-protected (:19) |
| Request | UpdateSettingDto — key/value/group/label/description (update-setting.dto.ts:5-26); upsert on {tenantId,key} unique (setting.schema.ts:38) |
| Response | setting doc(s); 404 on missing key (settings.service.ts:19-21) |
| Client | S4 reference tabs (read by group); writes owned by feature modules (cross-module) |
E11 — Standalone feature flags
| Endpoints | GET /api/v1/feature-flags?module= (feature-flags.controller.ts:23-28); GET /feature-flags/enabled (:30-34); GET /feature-flags/:key (:36-40); PUT upsert (:42-46); PUT /bulk (:48-52); DELETE /:key (:54-58) — JwtAuthGuard-protected |
| Request | UpdateFeatureFlagDto — key/enabled (required), label/description/module optional (update-feature-flag.dto.ts:4-27); upsert writes enabled+label only (feature-flag.repository.ts:32-43) |
| Response | flag doc; 404 missing key (feature-flags.service.ts:28-30) |
| Client | S5 catalog (labels/descriptions/module grouping); delete via menu |
E12 — Register (first admin)
| Endpoint | POST /api/v1/auth/register (auth.controller.ts:30-36) — public, @RateLimit auth 5/min (:31) |
| Request | RegisterDto (register.dto.ts:4-29) — firstName/lastName/email/password(≥8)/phone?/tenantId (slug) |
| Response | 201 data {accessToken, refreshToken} (auth.service.ts:95-99); side effects: user created + auth_account, member org_admin, role seeding, UserRegistered event → welcome email (event-queue-map.ts:7) |
| Errors | 409 email exists (auth.service.ts:56-58); 400; 429 |
| Client | onboarding flow; no org-existence check (OQ-2) |
E13 — RBAC (invite admin)
| Endpoints | GET /api/v1/rbac/members (rbac.controller.ts:57-61); POST /rbac/members (:63-67); PATCH /rbac/members/:id (:69-73); DELETE /rbac/members/:id (:75-79); GET /rbac/roles (:27-31) — all org_admin + JwtAuthGuard (:19-23) |
| Request | AddMemberDto {userId, roles[]} |
| Client | S6 list/invite/role change/remove |
Client contract summary (all screens)
| Concern | Rule |
|---|---|
| Auth | Bearer JWT; 401 → single-flight refresh → replay; fail → session expiry (00-shared/06 §3.6) |
| Optimistic | only flag toggles (E9); all other writes show server result (07 §9) |
| Idempotency | PATCH/DELETE retry-safe; no Idempotency-Key support confirmed (B6) |
| Offline | reads from last-good cache + banner; writes blocked (no module offline queue) |
| Pagination | page/limit/sort/q + meta exact (07 §5); infinite scroll driven by hasNext |
| Realtime | WS topics n/a for org config today; (forward-looking) org.branding.updated, org.feature-flags.updated |
| Error mapping | 00-shared/06 §5 table: 400 field errors, 403 hide/deny, 404 empty/not-found, 409 inline conflict, 429 backoff, 5xx generic+requestId |
13 — State Management (Organizations Module)
- 1. Cubit map
- 2. State machine (generic per 00-shared/06 §3.1)
- 3. OrgDetailCubit
- 4. OrgEditCubit
- 5. OrgSettingsCubit
- 6. FeatureFlagsCubit
- 7. TenantsCubit (
PaginatedListMixin<Organization>) - 8. Caching & staleness (module TTLs)
- 9. Realtime & cross-cubit
- 10. Testing hooks
Cubit architecture per 00-shared/06. One cubit per screen; base
LoadState(Initial/Loading/Success/Error(ApiException)),PaginatedListMixin, cache + SWR, optimistic updates, connectivity. All(proposed)client design.
1. Cubit map
| Cubit | Screen(s) | Data sources | Notes |
|---|---|---|---|
OrgDetailCubit | S1 Overview, S8 detail | E3 GET /organizations/:id | shared by self + platform (id from route/context); TTL 5 min SWR |
OrgEditCubit | S2 Edit, S3 Branding, S8 create/edit | E1/E4 + storage upload (planned) | form state not cached; submit → full DTO |
OrgSettingsCubit | S4 Settings tabs | E6 GET :id/settings + E7 PATCH; reference tabs E10 | full-object save; per-tab dirty flags |
FeatureFlagsCubit | S5 | E8 map + E11 catalog | merged view; optimistic toggle; rollback |
MembersCubit | S6 | E13 rbac endpoints + users module | invite/role/remove |
TenantsCubit | S7 | E2 list | PaginatedListMixin<Organization>; q/sort/filter state |
OnboardingCubit | global | org status + register | drives banner + registration flow (forward-looking) |
2. State machine (generic per 00-shared/06 §3.1)
stateDiagram-v2
[*] --> Initial
Initial --> Loading: Load
Loading --> Success: load(org) ok
Loading --> Error: ApiException
Error --> Loading: Retry
Success --> Success: Refresh / mutate (reconcile)
Success --> Error: mutation fails → rollback (toggle) or snackbar (form)
Success --> Loading: pull-to-refresh (bypass cache)
3. OrgDetailCubit
sealed class OrgDetailState {}
class OrgDetailInitial extends OrgDetailState {}
class OrgDetailLoading extends OrgDetailState {}
class OrgDetailSuccess extends OrgDetailState {
final Organization org; // domain model
final bool stale; // served from cache while revalidating
}
class OrgDetailError extends OrgDetailState { final ApiException e; }
Events: LoadOrg(id), RefreshOrg(), OrgUpdated(org) (post-save reconcile from Edit/Branding/Settings cubits via shared bus or direct repository cache invalidation — prefer cache-bust key + refetch).
Load flow: repo.getOrg(id) → cache hit (sl:{tenant}:org:{id}, TTL 5 min) → emit Success(stale:true) + background refetch → Success(stale:false). Miss → Loading → Success/Error.
Self-view (OQ-1): loadSelf() resolves id via (planned) /organizations/me; fallback (documented stopgap) list-lookup by slug — flagged in code comment.
4. OrgEditCubit
State: form (OrgFormModel), submitting, fieldErrors, conflicts {slug?, domain?}, saveResult.
Events: InitForm(org), FieldChanged(field, value), SlugPreview(name), Submit(), Discard().
Reducers: submit → submitting → on 200 Success (emit OrgUpdated for dependents) → snackbar; on 409 → conflicts[field] inline, no form reset; on 400 → fieldErrors mapped from envelope details[] (http-exception.filter.ts:103-107).
sequenceDiagram
participant W as OrgEditCubit
participant R as OrganizationsRepository
participant API as PATCH /organizations/:id
W->>W: Submit()
W->>R: update(id, dto)
alt 200
API-->>R: updated doc
R-->>W: Success(org)
W-->>UI: snackbar + OrgUpdated
else 409
R-->>W: ApiException(code=DUPLICATE_RESOURCE)
W-->>UI: conflicts[field] + scroll-to-field
else 400
W-->>UI: fieldErrors from details[]
end
5. OrgSettingsCubit
State: initial (per tab), dirty {attendance, academic, theme} (bitmap), saving, error.
Events: LoadSettings(id), TabChanged(tab), FieldChanged(tab, field, value), SaveAll(), RevertTab(tab).
Key reducer: SaveAll() always serializes the merged full object from lastServerSettings + dirty fields (full-replace safety — organizations.service.ts:134). Success → clear dirty + snackbar. Failure → keep dirty + error snackbar (retry safe).
stateDiagram-v2
[*] --> Idle
Idle --> Loading: LoadSettings
Loading --> Ready: settings fetched
Ready --> Ready: FieldChanged (dirty[tab]=true)
Ready --> Saving: SaveAll
Saving --> Ready: 200 → dirty cleared, snackbar
Saving --> Ready: 400 → field errors on tab (dirty kept)
Saving --> Ready: network → error snackbar + Retry
6. FeatureFlagsCubit
State: flags: Map<String, FlagView> where FlagView {key, label?, description?, module?, enabled, pending}; loading, error.
Events: Load(), Toggle(key), Retry(key), DeleteFlag(key).
Optimistic flow: Toggle → set enabled=!enabled, pending=true → repo setFlagMap(fullNewMap) (E9) → on 200 reconcile + pending=false + lightImpact; on error → rollback + pending=false + error snackbar with Retry (retry re-submits last-intended map). Map = merge(catalog keys, server map keys).
stateDiagram-v2
[*] --> Idle
Idle --> Loading: Load
Loading --> Ready: map + catalog merged
Ready --> PendingToggle: Toggle(key)
PendingToggle --> Ready: 200 reconcile
PendingToggle --> Ready: error → rollback + snackbar(Retry)
7. TenantsCubit (PaginatedListMixin<Organization>)
State: page (1), limit (20), sort (-createdAt), q, statusFilter (proposed), items, hasNext, isLoadingMore, loadState.
Events: LoadFirst(), LoadMore(), Search(q), ChangeSort(sort), ChangeFilter(status), Refresh(), TenantDeleted(id) (remove row + refetch meta).
Contract mirrors API exactly (pagination-query.dto.ts:5-54): on Search/ChangeSort/Filter → reset page=1, clear items, fetch; hasNext drives infinite scroll; Refresh bypasses cache.
sequenceDiagram
participant C as TenantsCubit
participant R as OrganizationsRepository
participant API as GET /organizations?page&limit&sort&q
C->>C: Search("spring")
C->>R: list(page:1, q:"spring", sort:"-createdAt")
API-->>R: data[] + meta {totalItems,totalPages,hasNext,...}
R-->>C: Success(items, meta)
C->>C: hasNext=true → LoadMore on scroll bottom
C->>R: list(page:2, ...)
8. Caching & staleness (module TTLs)
| Data | Cache key | TTL | Notes |
|---|---|---|---|
| Org detail | sl:{tenant}:org:{id} | 5 min SWR | RefreshIndicator bypasses |
| Tenants page | sl:{tenant}:orgs:{page}:{limit}:{sort}:{q} | 5 min | full query key (includes filter (proposed)) |
| Settings (embedded) | no client cache | — | form data; refetch on entry |
| Flag map | sl:{tenant}:flags | 5 min | invalidated on toggle success |
| Flag catalog | sl:{tenant}:flag-catalog | 24 h (reference) | labels/descriptions |
| Members | sl:{tenant}:members | 5 min | invalidated on invite/role change |
9. Realtime & cross-cubit
- No org-config WS topics today;
(forward-looking): subscribeorg.branding.updated,org.feature-flags.updated→ invalidate caches + refetch (B2: WS protocol unverified). - Cross-cubit invalidation: after E4/E7/E9 success → bump
org:{id}cache version; Overview re-fetches on focus (AppLifecycle/route pop). FeatureFlagsCubitis the module-level toggle source for other modules' gating (00-shared/06 §4).
10. Testing hooks
- Pure-Dart cubits, mocked repositories; widget tests per state machine (Loading/Success/Error/Empty) + optimistic rollback (
00-shared/06 §6). - Golden: full-object-save form with 3 tabs dirty states.
14 — QA Checklist (Organizations Module)
- 1. Functional (P0)
- 2. Security & isolation (P0)
- 3. Visual & design tokens
- 4. A11y (P0 per changed screen, A §12)
- 5. Performance (10 §1)
- 6. Offline matrix (10 §2)
- 7. Tablet / desktop / foldable
- 8. Localization
- 9. Animations & motion
- 10. Analytics (proposed contract)
- 11. Regression guardrails (code facts to never break)
Module checklist layered on 00-shared/10 baseline. Severity: P0 ship-blocking.
1. Functional (P0)
-
Provision:
POST /organizationswith name only → slug auto-derived exactly perslugify(organizations.service.ts:161-169); status =onboarding; 7 default roles seeded (organizations.service.ts:58;role.schema.ts:8-65). -
Slug conflict → 409
DUPLICATE_RESOURCEinline; domain conflict on update with own-org exemption (organizations.service.ts:115-122). -
Full-replace semantics:
PATCH :id/settingswith only{attendance}wipes academic/theme — client must always submit full object; verify no wipe path in UI (S4). -
Full-replace flags:
PATCH :id/feature-flagswith{a:true}removes keyb— UI must submit merged map; rollback restores prior map exactly. -
Delete → soft-deleted org absent from
GET /organizations+GET /:id(404) (organizations.repository.ts:15-19,60-66). -
Invalid ObjectId → 400
VALIDATION_ERROR"Invalid resource identifier." (http-exception.filter.ts:47-55,92). -
Settings collection:
PUT /settingsupsert idempotent; bulkPUT /settings/bulk; delete 404 path (settings.service.ts:36-41). -
Flags catalog: upsert persists only enabled+label (
feature-flag.repository.ts:32-43); delete soft. -
Register: duplicate email 409; password <8 → 400; tokens usable immediately (
auth.service.ts:54-121). -
Pagination contract:
meta.totalPages/hasNext/hasPreviousexact (pagination-query.dto.ts:41-54); page > totalPages → empty data,hasNext:false. -
qsearch case-insensitive on name+slug; sort-createdAtdefault (organizations.service.ts:78-87).
2. Security & isolation (P0)
-
OQ-4: tenants list + tenant detail reachable by tenant admin today (unguarded controller, unscoped repo) — client MUST gate to
isPlatformAdmin; server guard(planned). Test: tenant-admin token → route hidden + 403 screen. -
Cross-tenant
:idaccess → 404 (never 403-leak-of-existence); verify both self + platform surfaces. -
JWT
tenantIdnever sent in body (except public registertenantId— by design,register.dto.ts:27-29); all other requests derive from token. - Tokens in secure storage; no token in analytics/logs (10_QA_Baseline.md §3).
- 5xx internals never rendered — generic + requestId only.
- Typed-confirm delete: destructive button disabled until org name typed exactly.
-
No
Idempotency-Keydependency assumed for PATCH/DELETE (retry-safe by nature); register POST is rate-limited, no auto-retry.
3. Visual & design tokens
- No literal colors/spacing/durations in widget code (D §7); tokens only.
- Light + dark parity on all 8 screens (surface/cards contrast ≥3:1 chrome, ≥4.5:1 text — 09 §2).
- Status/plan badges: icon + label, never color-only (A §9); colorblind check (primary/error/success pairs with icons).
-
Slug/domain rendered
mono; numerals tabular (02 §2). - Golden tests: 8 screens × 3 sizes × light/dark (10 §9).
4. A11y (P0 per changed screen, A §12)
- TalkBack + VoiceOver full walkthrough: S1 → S4 save flow, S5 toggle, S8 typed-confirm.
-
Keyboard-only desktop: focus order toolbar → search → chips → list; ESC closes everything;
Nnew tenant; typed-confirm traps focus. - Text scale 2×: no clipping in cards/forms/badges (S4 chip rows wrap; header wraps).
- Toggle results announced via live region; errors announced + focus to first invalid field.
- Color picker hex fallback; contrast warning announced.
-
Focus ring visible
primary2 dp offset 2.
5. Performance (10 §1)
-
Tenants list: skeleton ≤200 ms; content ≤2 s network; 0 dropped frames scrolling 100-row list (
ListView.builder). -
PATCHround-trip feedback ≤100 ms press-state; save spinner consistent. - No duplicate parallel requests for same org id (cache + single-flight).
-
Flag map merge on 50+ flags: no rebuild jank (grouped
AnimatedSize, list virtualization). - Memory: no growth across 20 min of tab-switching (keep-alive disposal policy).
6. Offline matrix (10 §2)
| Scenario | Expected |
|---|---|
| Airplane → S1/S7 | cached data + AppOfflineBanner + stale badge |
| Offline → S2/S4 submit | blocked with guidance; draft kept in-memory; no silent loss on back (guard) |
| Offline → S5 toggle | switch disabled + banner (no offline queue) |
| Offline → online | auto-refresh on next interaction; no partial writes |
| 3G throttle | skeletons, no ANR, retry works |
| Network loss mid-save | pending → error snackbar "Couldn't save — retry"; safe retry (PATCH idempotent) |
7. Tablet / desktop / foldable
- ≥840 dp: S7 master-detail; breadcrumbs at depth ≥2; hover states; scrollbars visible.
- 600–839 dp: S1 two-column info grid; settings tabs readable.
- Foldable: cover + unfolded layouts no overlap; 200% text reflow.
- Landscape tablet: keyboard doesn't cover form fields (scrollable form).
8. Localization
-
All strings via keys (
app_en.arb+ org-locale); server business messages mapped to keys where available, fallback for 4xx business text (07 §11). -
Date/number formatting via
Intl(academic year, created dates). - RTL readiness: no hardcoded alignments (badges/rows use logical direction).
- Working-day chips localized weekday names (Sunday-first per locale).
- Smoke strings (fr/hi/es) don't break S4 labels or S1 cards.
9. Animations & motion
-
All transitions use
m-*tokens; reduced-motion: fades ≤m-instant, no stagger/shimmer/hero. - No dropped frames during hero S7→S8 (profile 120 fps).
- Haptics only per 10_Interaction_Specification.md §3; none on desktop.
10. Analytics (proposed contract)
-
Events fire per 05 inventory:
organizations.overview.view,organizations.edit.save.success|error,organizations.settings.save.*,organizations.flags.toggle.{key},organizations.tenants.list.view|search|delete.confirmed. - No PII beyond necessity; consent-gated (A4).
11. Regression guardrails (code facts to never break)
- Settings/flag save always full-object (test asserts sibling keys survive).
-
Slug change flows through confirm dialog; domain conflict check compares
existing.id !== id(organizations.service.ts:117). -
Create never accepts
statusfrom client (forcedonboarding,:55); update can change status. -
Register rate limit 5/min — UI countdown, no auto-retry (
auth.controller.ts:31).
15 — Flutter Implementation Guide (Organizations Module)
- 1. Structure
- 2. Models & enums (exact from schema)
- 3. Repositories
- 4. Cubits (see
13) - 5. Navigation (go_router)
- 6. Theme
- 7. Extensions
- 8. Localization
- 9. Testing
- 10. Performance
- 11. Open items to wire when backend lands
Module extension of 00-shared/11. Structure, widgets, cubits, repositories, DTOs, models, navigation, theme, extensions, localization, testing, performance. Forward-looking.
1. Structure
lib/features/organizations/
├── data/
│ ├── dto/
│ │ ├── organization_dto.dart # fromJson/toJson for envelope payload
│ │ ├── organization_create_dto.dart # mirrors CreateOrganizationDto
│ │ ├── organization_update_dto.dart # mirrors UpdateOrganizationDto
│ │ ├── organization_settings_dto.dart # mirrors UpdateOrganizationSettingsDto
│ │ ├── setting_dto.dart # mirrors UpdateSettingDto (standalone)
│ │ └── feature_flag_dto.dart # mirrors UpdateFeatureFlagDto
│ ├── models/
│ │ ├── organization.dart # domain model (enums: status/plan/subStatus)
│ │ ├── organization_settings.dart
│ │ ├── org_feature_flags.dart
│ │ └── flag_catalog_entry.dart
│ └── repositories/
│ ├── organization_repository.dart # E1–E9
│ └── org_config_repository.dart # E10–E11 (settings + flags collections)
├── domain/
│ └── slugify.dart # pure fn mirroring organizations.service.ts:161-169
└── presentation/
├── cubit/
│ ├── org_detail_cubit.dart
│ ├── org_edit_cubit.dart
│ ├── org_settings_cubit.dart
│ ├── feature_flags_cubit.dart
│ ├── members_cubit.dart
│ └── tenants_cubit.dart
├── pages/
│ ├── org_overview_page.dart # S1
│ ├── org_edit_page.dart # S2
│ ├── branding_page.dart # S3
│ ├── org_settings_page.dart # S4
│ ├── feature_flags_page.dart # S5
│ ├── members_page.dart # S6
│ ├── tenants_page.dart # S7
│ └── tenant_detail_page.dart # S8 (+ create)
└── widgets/
├── org_header.dart
├── status_badge.dart
├── setting_field_group.dart
├── color_picker_field.dart
├── day_chips.dart
├── flag_switch_row.dart
├── tenant_row.dart
└── slug_preview.dart
2. Models & enums (exact from schema)
enum SubscriptionPlan { free, basic, premium, enterprise } // organization.schema.ts:6-11
enum SubscriptionStatus { active, inactive, suspended, trial }// :13-18
enum OrganizationStatus { active, inactive, suspended, onboarding } // :20-25
enum SettingGroup { academic, attendance, grading, notification, theme, general } // setting.schema.ts:7-14
Organization fields mirror organization.schema.ts:28-152: name, slug, logoFileId, domain, contact{email,phone,website}, address{street,city,state,country,zip}, timezone, currency, academicYear{startDate,endDate,month}, subscriptionPlan/Status, settings, branding{primaryColor,secondaryColor,logo,favicon}, status, metadata, createdAt/updatedAt, version. Ids String; dates DateTime (parse ISO from envelope timestamps). Never send DTOs to widgets (00-shared/11 §4).
3. Repositories
OrganizationRepository (dio, via AppDio bearer/refresh/error interceptors — 00-shared/11 §5):
createOrg(CreateOrgDto) → Organization(E1)listOrgs({page,limit,sort,q}) → Paginated<Organization>(E2)getOrg(id) → Organization(E3)updateOrg(id, UpdateOrgDto) → Organization(E4)deleteOrg(id) → void(E5)getSettings(id) → OrgSettings(E6),updateSettings(id, OrgSettings) → Organization(E7)getFlagMap(id) → Map<String,bool>(E8),setFlagMap(id, Map<String,bool>) → Map<String,bool>(E9)getSelfOrg() → Organization— OQ-1: calls(planned)/organizations/me; stopgap:listOrgs(q: tenantIdSlug)documented in code.
OrgConfigRepository: settings collection E10 + flags catalog E11.
Typed exceptions: ApiException(code, status, fieldDetails, message) from error interceptor (00-shared/06 §5).
4. Cubits (see 13)
OrgDetailCubit, OrgEditCubit, OrgSettingsCubit, FeatureFlagsCubit, MembersCubit, TenantsCubit (with PaginatedListMixin<Organization> — 00-shared/06 §3.2). Full-object save guard in OrgSettingsCubit.SaveAll (merge from lastServer + dirty). Optimistic rollback in FeatureFlagsCubit.Toggle. All pure-Dart, DI via get_it lazy factories (00-shared/11 §2).
5. Navigation (go_router)
GoRoute(path: '/organization', builder: OrgOverviewPage, guards: [authGuard, permissionGuard('organization.read')]),
GoRoute(path: '/organization/edit', ..., guards: [..., permissionGuard('organization.update')]),
GoRoute(path: '/organization/branding', ...),
GoRoute(path: '/organization/settings', ...),
GoRoute(path: '/organization/feature-flags', ...),
GoRoute(path: '/organization/members', ...),
GoRoute(path: '/admin/tenants', guards: [authGuard, platformAdminGuard]),
GoRoute(path: '/admin/tenants/new', ...),
GoRoute(path: '/admin/tenants/:id', ...),
GoRoute(path: '/admin/tenants/:id/edit', ...),
platformAdminGuard checks user.isPlatformAdmin (jwt-auth.guard.ts:54). Permission guards mirror permissions.constants.ts (organization.* :2-5, settings.* :75-77, feature-flags.* :78-80); server remains authoritative (403 → route redirect to 403 screen). Master-detail via StatefulShellBranch at ≥840 dp (00-shared/05 §3). Deep links (forward-looking): studylyon://organization/settings, studylyon://admin/tenants/:id.
6. Theme
Branding override: AppTheme.fromBranding(seed: org.branding.primaryColor) → ColorScheme.fromSeed (D §7.5, 02 §1); preview mode in S3 renders with overridden scheme without persisting. themeMode from system or org pin (proposed) (OQ/D). No literal tokens in widgets (02 §10, 04 §7).
7. Extensions
Reuse shared (00-shared/11 §8): DateTime.toDisplayDate, String.initials, int.toMoney, context.showAppSnackbar. Module additions: OrganizationStatus.displayName, Organization.statusSemantics, Map<String,bool>.flagKeysBy(module), String.toSlug (wraps slugify).
8. Localization
Keys under features/organizations/ namespace in .arb (en + fr + hi smoke): org.title, org.status.onboarding, org.settings.saveAll, org.flags.toggle.rollback, org.tenants.delete.confirm, org.slug.conflict, weekday labels for chips. Server messages rendered via error-code→key map with business-4xx fallback (00-shared/11 §9, 07 §11).
9. Testing
| Layer | Coverage |
|---|---|
| Unit | slugify parity tests vs organizations.service.ts:161-169 vectors ("St. Mary's School" → st-marys-school); enum mapping; formatters; full-object merge logic |
| Cubit | OrgSettingsCubit full-object save (assert sibling groups present in payload); FeatureFlagsCubit rollback; TenantsCubit pagination + search reset |
| Widget | S1 loading/error/empty; S4 3-tab dirty states; S5 toggle pending/rollback; S7 skeleton/infinite-scroll; typed-confirm disabled state |
| Golden | 8 screens × light/dark × 3 sizes; new components (07 §Golden) |
| Integration | provision → register → overview journey; settings save → reload → values intact |
| E2E (device cloud) | P0: platform creates tenant; admin registers; edits settings; toggles flag; deletes tenant |
Run: flutter analyze, flutter test, flutter test integration_test (00-shared/11 §12).
10. Performance
ListView.builderfor S5/S6/S7;AutomaticKeepAliveClientMixinfor S4 tabs;RepaintBoundaryaround S3 preview.- Flag list grouped sections render lazily; collapse via
AnimatedSizewithout layout rebuild storms. SlugPreviewdebounced 150 ms; search debounce 300 ms.- Caches keyed
sl:{tenant}:org…with TTLs from13 §8;RefreshIndicatorbypasses cache. - Profile on mid-range device against
10_QA_Baseline.md §1budgets.
11. Open items to wire when backend lands
GET /organizations/me(OQ-1) — remove stopgap lookup.- JWT+RBAC guard + tenant scoping on organizations controller (OQ-4) — swap
platformAdminGuardfor server truth. TenantPurgeJobenqueue (OQ-5) — surface "purge scheduled" state if API returns it.- Storage provider (R2/Appwrite) — logo upload real path (
IMPLEMENTATION_PLAN.md:24-34). - WS topics
org.branding.updated/org.feature-flags.updated— subscribe + cache invalidation. - Institution-type discriminator (Phase 6,
IMPLEMENTATION_PLAN.md:251-287) — addinstitutionTypeto model + onboarding step(planned).
01 — Product Overview (Parents Module)
- 1. Purpose
- 2. Business goals
- 3. User goals
- 4. Stakeholders
- 5. Why this exists
- 6. Dependencies
- 7. Success metrics (proposed)
- 8. Edge cases
- 9. Assumptions (module)
- 10. Open questions (module-level; global ledger in 00-shared/12)
- 11. Glossary (this module)
StudyLyon — multi-tenant ERP / School Management API. This package designs the Parents module client (guardian profiles, student–parent linking, multi-child support) against the implemented NestJS backend. All endpoints, DTO fields, schemas, events, and wire contracts are derived directly from
src/modules/parents/**,src/modules/students/**,src/modules/users/**,src/modules/rbac/**,src/infrastructure/**,studylyon-blueprint/04-Modules/Parents.md, andstudylyon-blueprint/03-Database/COLLECTIONS.md. No feature is invented; gaps are flagged(planned)/(forward-looking)/(proposed)and itemized in the Assumptions & Open Questions section.
1. Purpose
The Parents module manages guardian identity (occupation, company, income,
emergency-contact priority, pickup authorization) and the many-to-many graph
between guardians and students (student_parent_links). The blueprint defines the
module as "Guardians and emergency contacts" (04-Modules/Parents.md:3) with four
responsibilities: guardian profile, student–parent linking, relationship type, and
emergency-contact/pickup/financial flags (04-Modules/Parents.md:11-17).
| Responsibility | Source |
|---|---|
| Parent profile CRUD (occupation, company, income, emergency flags) | parent.schema.ts:8-32, parent.controller.ts:29-46 |
One parents doc per users doc (unique tenantId+userId) | parent.schema.ts:36 |
| Link parents ↔ students (many-to-many) with relationship metadata | student-parent-link.schema.ts:16-41, student-parent-link.service.ts:16-41 |
| Relationship type enum (mother, father, guardian, grandparent, relative, foster_parent) | student-parent-link.schema.ts:7-14 |
isPrimaryGuardian — primary-communication flag on the link | student-parent-link.schema.ts:27-29 |
| Financial responsibility / pickup authorization / emergency priority | student-parent-link.schema.ts:30-38, parent.schema.ts:24-29 |
| Domain events → BullMQ (in-app notification, audit) | parent.service.ts:38-45,78-86,92-99, event-queue-map.ts:37-39 |
| Soft delete (never hard delete; links survive parent deletion) | parent.service.ts:89-100, base.repository.ts:68-74 |
Identity split (critical architectural rule):
parentsstores guardian business data only. Name, email, phone, avatar live onusers(COLLECTIONS.md §2.3"Domain collections store domain data";users/schemas/user.schema.ts:16-78). The client must always resolve a parent's display identity through the linkedUser(parent.schema.ts:9-10).
2. Business goals
| Goal | Measure / evidence |
|---|---|
| One guardian profile, many children | Link table is the only M2M (student-parent-link.schema.ts:18-25); no child list stored on parents |
| Never duplicate profile data | parents.userId required ref (parent.schema.ts:9); identity lives in users |
| Primary-guardian semantics per child | isPrimaryGuardian per link (student-parent-link.schema.ts:27) |
| Emergency-contact order per child | emergencyPriority on both profile (default 0, parent.schema.ts:24-25) and link (default 0, student-parent-link.schema.ts:36-37) |
| Pickup authorization per child | pickupAuthorization (profile, default false) + pickupAllowed (link, default true) — note the opposite defaults (parent.schema.ts:27-28, student-parent-link.schema.ts:33-34) |
| Removing a link never deletes a parent linked elsewhere | DELETE /parents only soft-deletes the parent doc (parent.service.ts:89-100); link rows are independent (student-parent-link.schema.ts) |
| Multi-tenant isolation | every query scoped tenantId + isDeleted:false (base.repository.ts:20-30); parent index {tenantId, userId} unique (parent.schema.ts:36) |
3. User goals
- Org admin / admission staff: create a guardian profile from an existing user, link the guardian to one or more students, mark the primary guardian, switch primary when custody changes, unlink a guardian cleanly.
- Parent / guardian: log in and see only their linked children (privacy
boundary — persona: "Access: Only linked children",
USER_PERSONAS.md:48-53), identify their own relationship per child (mother/father/…), see who is the primary guardian, update their own emergency-contact/pickup flags. - Multiple-guardian households: mother + father + grandparent each have their own login; each sees the same children; primary flag is per child.
- Admission staff: during enrollment, attach guardians to the newly created
student record (
POST /students→ then link).
4. Stakeholders
Institution admins, admission/front-desk staff, teachers (view linked guardians),
parents/guardians (self-service, forward-looking), accountants (financial-responsibility
flag feeds billing — fees module), platform support (audit trail via ParentUpdated /
ParentDeleted audit events, event-queue-map.ts:38-39), QA + design + engineering.
5. Why this exists
Guardians are the school's primary external stakeholder: they receive attendance,
results, and fee communications (blueprint: "Notifications — absentee/result alerts to
linked parents", 04-Modules/Parents.md:49). Correctness of the link graph and the
primary-guardian flag determines who gets billed, who gets pickups, and who gets
called in an emergency — three flows where a wrong link is a real-world incident.
6. Dependencies
| Dependency | Role | Source |
|---|---|---|
| Users module | identity profile the parent links to (userId) | parent.schema.ts:9; users.service.ts:49 |
| Students module | students must exist before linking | student-parent-link.service.ts:27 (imports StudentRepository) |
| RBAC | global RbacGuard + parent role (student.read only) | app.module.ts:131, role.schema.ts:50-56 |
| Events / BullMQ | ParentCreated → in-app parent-created; ParentUpdated/ParentDeleted → audit | event-queue-map.ts:37-39 |
| Mongo collections | parents, student_parent_links | COLLECTIONS.md (§ parents / § student_parent_links) |
| BaseRepository / TenantContext | tenant scoping + soft-delete filtering | base.repository.ts:20-36 |
7. Success metrics (proposed)
- Zero cross-tenant parent reads (repository scoping structural,
base.repository.ts:20-30). - Link graph integrity: every link's
studentId/parentIdresolves to a non-deleted doc in the same tenant (today not enforced — see OQ-3). - Parent self-service adoption: % of parents who log in (requires
(forward-looking)my-children endpoints — see OQ-1). - Duplicate-link incidents = 0 (today not prevented — OQ-2).
- Emergency-contact list accuracy: % of children with ≥ 1 primary guardian (not enforced).
8. Edge cases
- Duplicate profile: second
POST /parentswith the sameuserId→ 409DUPLICATE_RESOURCE"Parent profile already exists for this user." (parent.service.ts:30-34, enforced by unique indexparent.schema.ts:36). - Parent not found:
GET/PATCH/DELETE /parents/:idandGET /parents/:id/students→ 404RESOURCE_NOT_FOUND"Parent not found." (parent.service.ts:51,77,91,70). - Link to missing student:
POST /parents/link/:studentIdcallsstudentRepo.findById(studentId)but discards the result — a link to a non-existent student is silently created (OQ-3;student-parent-link.service.ts:27). - Duplicate link: same
(studentId, parentId)twice → two link docs allowed (index non-unique,student-parent-link.schema.ts:46) (OQ-2). - Multiple primary guardians: no server rule prevents two links with
isPrimaryGuardian:truefor one student (OQ-5). - Unlink:
DELETE /parents/link/:linkIdsoft-deletes the link; if it was the only link, no parent deletion happens and no re-promotion of another guardian occurs (OQ-4). - Delete parent with active links: parent soft-deleted, links remain orphaned (pointing at a soft-deleted parent) — no cascade (OQ-3).
- Invalid relationship string:
LinkParentDto.relationshipis@IsString()with no enum validation (link-parent.dto.ts:19-20); schema enum rejects on save → MongooseValidationError→ 500INTERNAL_SERVER_ERRORinstead of 400 (OQ-6). - Invalid ObjectId in
:id:CastError→ 400VALIDATION_ERROR"Invalid resource identifier." (http-exception.filter.ts:48,92). - Parent list ignores
qandsort:find()applies only skip/limit (parent.service.ts:58-61) — search UI must not rely onq(OQ-7).
9. Assumptions (module)
- Mobile client is forward-looking: backend is complete; this package is the UI-side spec (same policy as the auth package — PRD Phase 1 excludes native apps, 00-shared/12 A1).
- RBAC is not wired to these endpoints. Global
RbacGuardruns (app.module.ts:131), butParentControllerdeclares only@UseGuards(JwtAuthGuard)(parent.controller.ts:23) and no@Permissions(...); theparent.*permissions in the blueprint (04-Modules/Parents.md:64-70) do not exist inALL_PERMISSIONS(permissions.constants.ts:1-97). Any authenticated user can create/read/update/delete any parent in their tenant. UI must be built with intended-role gating and harden when RBAC lands (OQ-8). - There is no "my parent profile" resolution. No
GET /parents/meand nouserIdfilter onGET /parents; a logged-in parent cannot resolve their own parent profile or children with today's API. The my-children surface is(forward-looking)and needs either a new endpoint or a client-side mapping (OQ-1). Parent role carries onlystudent.read(role.schema.ts:55). - Parent user accounts are created by admins via
POST /users(no invite/password bootstrap specific to parents; noauth_accountscreation from the users module) (OQ-9).ParentCreatedfires only afterPOST /parents(parent.service.ts:38-45). - No email on parent creation:
ParentCreatedroutes to thein-appqueue (event-queue-map.ts:37);email.worker.tshandles onlyUserRegisteredandPasswordResetRequested(email.worker.ts:25-42). - Blueprint link routes (
POST /students/:id/parents,DELETE /students/:id/parents/:linkId,04-Modules/Parents.md:29-30) differ from the implemented controller routes (/parents/link/...,parent.controller.ts:47-60). Implementation wins; blueprint is historical.
10. Open questions (module-level; global ledger in 00-shared/12)
| # | Item | Impact |
|---|---|---|
| OQ-1 | No endpoint to fetch "my parent profile / my children" for the parent role (no /parents/me, no userId filter). Add? | my-children screen is (forward-looking); privacy boundary cannot be exercised today |
| OQ-2 | Duplicate (studentId, parentId) links not prevented (non-unique index, no check). Add unique index + 409? | QA duplicate-link case, link sheet double-submit |
| OQ-3 | linkStudentParent ignores the findById result; links to non-existent students, and links referencing soft-deleted parents, are stored. Enforce? | QA link-validation case; data integrity |
| OQ-4 | Unlink does not reassign isPrimaryGuardian when the primary is unlinked; no rule enforces exactly one primary per student. | primary-guardian switch UX; "who gets billed/called" |
| OQ-5 | isPrimaryGuardian on two links for one student is allowed. Client must self-enforce (or server adds exclusivity). | QA primary-guardian case |
| OQ-6 | relationship in LinkParentDto is free string (@IsString()), schema enum is the only gate → invalid value yields 500, not 400. Add @IsEnum(RelationshipType)? | client must always send valid enum; error mapping |
| OQ-7 | GET /parents ignores q and sort query params (only page/limit applied, parent.service.ts:58-61). | parents list has no search/sort; UI must not fake it |
| OQ-8 | No parent.* permissions exist in ALL_PERMISSIONS; endpoints JWT-only. When does RBAC land? | role-gating of every screen; who may link/unlink |
| OQ-9 | No parent-invitation flow (user + auth account + welcome email). Backend auth_accounts creation is out of the users module's scope. | how a parent obtains login credentials |
| OQ-10 | GET /parents/:id/students returns raw link docs (studentId ObjectIds, student-parent-link.service.ts:34-36) — no populate or student summary. Client must join. | linked-children cards need N+1 fetches or a (planned) enriched endpoint |
11. Glossary (this module)
| Term | Meaning |
|---|---|
| Parent / Guardian | Business profile (parents) linked to identity (users.userId) |
| Link | student_parent_links doc: one parent–student relationship with metadata |
| RelationshipType | mother | father | guardian | grandparent | relative | foster_parent (student-parent-link.schema.ts:7-14) |
| Primary guardian | isPrimaryGuardian flag per link; intended recipient of primary communications (04-Modules/Parents.md:57) |
| Emergency priority | emergencyContactPriority (profile) / emergencyPriority (link), default 0 |
| Pickup | pickupAuthorization (profile, default false) / pickupAllowed (link, default true) |
| Financial responsibility | financialResponsibility (link, default false) |
| Envelope | {success,message,data,meta?,timestamp,requestId} (response-envelope.interceptor.ts:11-62) |
02 — User Personas (Parents Module)
- 1. Organization Admin (org_admin)
- 2. Admission / Front-desk Staff (CRM-adjacent)
- 3. Parent / Guardian (self-service,
(forward-looking)) - 4. Multiple-Guardian Household (mother + father + grandparent)
- 5. Teacher / Staff (read-only consumer)
- 6. System actors
- 7. Role → parents-surface matrix
Roles, goals, permissions, and expected behavior for every consumer of the Parents module. Permission values are exact from
rbac/schemas/role.schema.ts(DEFAULT_ROLES) andrbac/permissions.constants.ts(ALL_PERMISSIONS). Server fact: parent endpoints are JWT-only today —parent.controller.ts:23declares onlyJwtAuthGuard; no@Permissions()metadata; noparent.*permission exists inALL_PERMISSIONS(permissions.constants.ts:1-97). Permission columns below state the intended model (from blueprint04-Modules/Parents.md:64-70, flagged(planned)) and the actual server posture.
1. Organization Admin (org_admin)
| Attribute | Detail |
|---|---|
| Permissions (actual) | ALL_PERMISSIONS (role.schema.ts:23) — but none of them are parent-scoped; all parent ops work for them anyway (JWT-only) |
| Frequency | Daily during admissions/custody changes; occasional otherwise |
| Goal | Complete guardian picture: create profiles, link parents to students, manage primary guardians, resolve billing/pickup disputes |
| Core job | POST /parents (with existing userId from POST /users), POST /parents/link/:studentId, PATCH /parents/:id, DELETE /parents/link/:linkId |
| Pain points | Duplicate profiles per parent (only prevented by 409 on same userId); no search on the parent list (parent.service.ts:58-61 ignores q); no audit UI yet |
| Mental model | "Every child needs at least one guardian who answers the phone." |
| Expected behaviour | List parents → detail (linked children) → edit → link/unlink; confirm destructive unlink |
2. Admission / Front-desk Staff (CRM-adjacent)
| Attribute | Detail |
|---|---|
| Permissions (actual) | role-scoped (e.g., student.read + crm.read/crm.lead.manage — permissions.constants.ts:33-34,47); parent endpoints are JWT-only so they can operate today |
| Frequency | Peak season: dozens of admissions/week |
| Goal | Enrollment flow: student record exists → attach mother/father/guardian → mark primary → set pickup |
| Core job | POST /students (student.controller.ts:38), then POST /parents/link/:studentId with relationship, isPrimaryGuardian, pickupAllowed |
| Pain points | Link is a separate step after student creation (no auto-link on enrollment despite blueprint "Consumes: StudentCreated (auto-link…)" 04-Modules/Parents.md:41 — not implemented); parents of siblings must be re-linked per child |
| Mental model | "Enroll → then 'who do we call?'" |
| Note | The blueprint link routes (POST /students/:id/parents, 04-Modules/Parents.md:29-30) are not what the controller implements — use /parents/link/:studentId (parent.controller.ts:47-52). |
3. Parent / Guardian (self-service, (forward-looking))
| Attribute | Detail |
|---|---|
| Permissions (actual) | role parent = ['student.read'] only (role.schema.ts:50-56) — no parent. permission*; profile created by admin |
| Frequency | 2–10×/month during term (attendance alerts, results, fees) |
| Goal | See only own linked children (privacy boundary, USER_PERSONAS.md:48-53), check own relationship/primary flag, update emergency/pickup flags, receive absentee/result alerts (04-Modules/Parents.md:49) |
| Core job | Log in → my children → per-child details (attendance/results via other modules, student.read) → my profile edit |
| Pain points | Cannot resolve "my profile" today: no /parents/me, no userId filter on the list (OQ-1 in 01_Product_Overview.md) |
| Restrictions | Sees data only for linked children; cross-tenant access structurally impossible (base.repository.ts:20-30) |
| Mental model | "This school app knows which kids are mine." |
| Platform note | Parent student.read is honored by other modules' authorization patterns (planned); the parents module itself has no owner check (OQ-8) |
4. Multiple-Guardian Household (mother + father + grandparent)
| Attribute | Detail |
|---|---|
| Permissions (actual) | each guardian = separate User → separate parents doc → separate links (parent.schema.ts:36 unique per userId) |
| Goal | Each guardian sees the shared children; exactly one primary per child for communications; pickup rights split |
| Core job | Admin links all three via POST /parents/link/:studentId per child; primary set per link |
| Pain points | Server allows multiple primaries per student (no exclusivity, OQ-5); switching primary = PATCH of two links (old false / new true) — no dedicated endpoint; unlink of primary does not auto-promote (OQ-4) |
| Expected behaviour | UI: per-child guardian list with radio "Primary guardian"; switching writes both links; unlink blocks if it would leave zero guardians or warns when removing the primary |
| Mental model | "Grandma can pick up, but Dad gets the bills." (billing flag = financialResponsibility, student-parent-link.schema.ts:30-32) |
5. Teacher / Staff (read-only consumer)
| Attribute | Detail |
|---|---|
| Permissions (actual) | student.read (teacher/staff, role.schema.ts:31,39) |
| Goal | From a student profile, reach linked guardians for a call/note (reuses GET /parents/link/student/:studentId, parent.controller.ts:53-57) |
| Restrictions | Must not edit guardian data; today the JWT-only API would allow it — UI hides edit for this role |
| Mental model | "Who do I contact for this child?" |
6. System actors
- EventBus → QueueBridge:
ParentCreated→in-appparent-createdjob;ParentUpdated/ParentDeleted→audit-writejobs (event-queue-map.ts:37-39). No email job for parent events (email.worker.ts:25-42). - Audit logger: append-only records of parent create/update/delete.
7. Role → parents-surface matrix
| Surface | org_admin | admission staff | parent | teacher/staff |
|---|---|---|---|---|
Parents list (/parents) | ✓ | ✓ | — | — |
| Parent detail + linked children | ✓ | ✓ | — | — |
| Create / edit parent profile | ✓ | ✓ | self-edit (forward-looking) | — |
| Link / unlink guardians | ✓ | ✓ | — | — |
| Set / switch primary guardian | ✓ | ✓ | view-only | — |
| My children + child switcher | — | — | (forward-looking) | — |
| Student detail → guardians | ✓ | ✓ | own children only | read |
Intended permission model (planned): parent.read, parent.create, parent.update,
parent.delete, parent.link (04-Modules/Parents.md:64-70) — none present in
ALL_PERMISSIONS today (permissions.constants.ts). UI must gate on these once seeded
and treat server as JWT-only until then.
03 — User Journeys (Parents Module)
- 1. Create parent profile
- 2. Link parent to student
- 3. Unlink parent ↔ student / switch primary guardian
- 4. Parent logs in → sees own children (self-service,
(forward-looking)) - 5. Parent updates own profile (self-service,
(forward-looking)) - 6. Cross-cutting
End-to-end journeys computed from
parent.controller.ts,parent.service.ts,student-parent-link.service.ts,student.controller.ts, andusers.service.ts. Each journey: entry, intent, decision points, system responses, loading, failures, recovery, exit, back navigation, abandonment, permission denial, offline.(planned)/(forward-looking)marks per global rules.
1. Create parent profile
entry: admin/admission staff → Parents → "Add guardian"
intent: record a guardian's business profile for an existing user
precondition: the user account exists (POST /users, users.service.ts:49)
sequenceDiagram
actor A as Admin/Admission staff
participant F as ParentFormPage
participant R as ParentsRepository
participant API as POST /parents
A->>F: search & pick user (users list), fill occupation/company/flags
F->>R: submit(CreateParentDto)
R->>API: {userId, occupation?, company?, annualIncome?, relationshipNotes?,<br/>emergencyContactPriority?, pickupAuthorization?}
alt success
API-->>R: 200/201 envelope.data = parent doc (id, userId, flags, timestamps)
R-->>F: navigate to parent detail
Note over API: ParentCreated → in-app 'parent-created' (event-queue-map.ts:37)
else 409 DUPLICATE_RESOURCE
API-->>R: "Parent profile already exists for this user." (parent.service.ts:32-34)
F-->>A: inline banner + link to existing profile
else 400 VALIDATION_ERROR
API-->>R: field details (userId not MongoId, etc.)
F-->>A: per-field errors
end
- Decision points: which
User(search by name/email viaGET /users?q=,users.service.ts:90-115); pickup authorization default off (parent.schema.ts:28). - Loading: CTA spinner; anti-double-submit (
00-shared/08 §6). - Failure covers: duplicate user (409), invalid id (400), offline (banner, blocked), rate limit (429 countdown), 5xx (generic + requestId).
- Exit: success → detail; back = list. Abandonment: form state lost; user pick kept client-side only.
- Privacy: parentId is tenant-scoped; no cross-tenant read possible
(
base.repository.ts:20-30).
2. Link parent to student
entry: parent detail → "Link to student"; student detail → "Add guardian"
intent: create one relationship row with role, primary flag, pickup, financial flags
precondition: parent and student docs exist in the same tenant
sequenceDiagram
actor A as Admin/Admission staff
participant S as LinkSheet
participant R as ParentsRepository
participant API as POST /parents/link/:studentId
A->>S: search student (admission number/name) or pick from student page
S->>R: submit(LinkParentDto)
R->>API: {parentId, relationship, isPrimaryGuardian?, financialResponsibility?,<br/>pickupAllowed?, emergencyPriority?}
API->>DB: studentRepo.findById(studentId) — result DISCARDED (student-parent-link.service.ts:27)
API->>DB: linkRepo.create({studentId, parentId, ...}) — tenantId injected (base.repository.ts:33-35)
alt success
API-->>R: envelope.data = link doc (relationship, flags, timestamps)
R-->>F: sheet closes; guardian list refreshes (link appears)
else 404
API-->>R: "Student not found." — only if findById throws (today: no-op, OQ-3)
end
- Decision points: relationship (6 values,
student-parent-link.schema.ts:7-14); primary guardian toggle; pickup default true on the link (student-parent-link.schema.ts:33-34). - Known server gaps (design around them):
- Duplicate link not detected → second row appears (OQ-2) → client should pre-check existing links and warn "already linked".
- Missing student not detected → link row created anyway (OQ-3).
relationshipis free text server-side (link-parent.dto.ts:19-20) → client must send only enum values (OQ-6).
- Primary-guardian UX: if the student already has a primary, warn before creating a second primary (server allows it, OQ-5).
- Exit: success → sheet closes; back = cancel sheet.
3. Unlink parent ↔ student / switch primary guardian
entry: parent detail → linked child row → menu; or student detail → guardian row → menu
intent: remove a relationship (transfer, custody change, error) or promote another guardian
sequenceDiagram
actor A as Admin
participant D as UnlinkDialog
participant R as ParentsRepository
participant API as DELETE /parents/link/:linkId
A->>D: "Unlink guardian from student?"
D->>R: confirm(linkId)
R->>API: DELETE /parents/link/:linkId
API->>DB: linkRepo.softDelete(linkId) (student-parent-link.service.ts:38-41)
alt success
API-->>R: envelope.data = undefined (void)
R-->>F: row removed; snackbar
Note over API: no ParentDeleted/update event fired for unlink (only ParentUpdated/Deleted, event-queue-map.ts:38-39)
else 404 RESOURCE_NOT_FOUND
API-->>R: "Link not found." (student-parent-link.service.ts:40)
F-->>A: treat as already removed
end
- Switch primary guardian (two-step, no dedicated endpoint):
PATCHthe new primary link →{isPrimaryGuardian:true}thenPATCHthe old →{isPrimaryGuardian:false}(PATCH /parents/:idcannot touch links — links are only changed by create/delete; so the client calls link-creation? No — there is no link-update endpoint at all:StudentParentLinkServiceexposes onlylinkStudentParent,findByStudent,removeLink(student-parent-link.service.ts:16-41). → Switching primary requires delete + recreate the links (OQ-4/OQ-5). - Unlink of the primary guardian: warn "This student will have no primary guardian"; server does not auto-promote (OQ-4).
- Unlink never deletes the parent — parent profile persists even with zero links
(
04-Modules/Parents.md:58). - Permission denial: JWT-only today; UI gates by intended role
(planned).
4. Parent logs in → sees own children (self-service, (forward-looking))
entry: parent opens app → login → home
intent: land on "My children", switch between kids, deep-dive each child
precondition: parent has ≥ 1 link; user has parent role (role.schema.ts:50)
sequenceDiagram
actor P as Parent
participant H as Home/MyChildrenPage
participant R as ParentsRepository
participant API as (planned) GET /parents/me or /parents?userId=
P->>H: open My Children
H->>R: loadMyChildren()
R->>API: (forward-looking — no endpoint today, OQ-1)
Note over R,API: BLOCKER: no /parents/me, no userId filter,<br/>no "links for my profile" query (parent.service.ts:55-67, findLinks by id only)
alt endpoint exists (future)
API-->>R: links[] → child studentIds
R->>API: GET /students/:id ×N (join, OQ-10)
H-->>P: child switcher (avatar list) + per-child dashboard
else today
R-->>H: (forward-looking) — client cannot resolve; show "coming soon"
end
- Privacy boundary: the intent is "only linked children" (
USER_PERSONAS.md:48-53); today the parent role only carriesstudent.readand has no API to enumerate its own profile (OQ-1). This journey is the top(forward-looking)item. - Child switcher: persists selected child in app state (
13_State_Management.md); deep linksstudylyon://students/:idopen a child if linked. - Offline: last-good children list cached (
00-shared/06 §3.3).
5. Parent updates own profile (self-service, (forward-looking))
entry: my profile → edit
intent: update occupation/company/emergency priority/pickup authorization
sequenceDiagram
actor P as Parent
participant F as MyProfilePage
participant R as ParentsRepository
participant API as PATCH /parents/:id
P->>F: change pickupAuthorization + emergencyContactPriority
F->>R: submit(UpdateParentDto)
R->>API: PATCH /parents/:id {pickupAuthorization, emergencyContactPriority}
API->>DB: findById → updateById ($set + $inc version) (parent.service.ts:74-77)
alt success
API-->>R: envelope.data = updated parent doc
F-->>P: snackbar "Profile updated"; audit job ParentUpdated (event-queue-map.ts:38)
else 404
API-->>R: "Parent not found." (parent.service.ts:77)
else 400
API-->>R: validation details
end
- Caveat:
PATCH /parents/:idaccepts any caller with a JWT (no owner check, OQ-8). When RBAC lands, self-edit must still work for the parent's own profile — the UI must know the parent'sid(via the(forward-looking)my-profile resolution). - Cannot update
userId—UpdateParentDtohas nouserId(update-parent.dto.ts:4-34). - Cannot update link-level flags here — relationship/primary/pickup-per-child live on the link and are immutable after create (no link PATCH endpoint) (OQ-4).
6. Cross-cutting
| Entry | Behavior | Status |
|---|---|---|
Deep link studylyon://parents/:id | open parent detail (admin) | client (forward-looking) |
Deep link studylyon://students/:id | open child (parent: only if linked) | client (forward-looking) |
| Push "new guardian linked" | open parent detail | (planned) push infra (00-shared/12 B3) |
ParentCreated in-app notification | "guardian profile created" job parent-created (event-queue-map.ts:37) | implemented queue, UI (planned) |
Abandonment & exit rules: back = previous screen (never shell home); timeout none
(server stateless); permission denial → 403 screen (planned); offline = cached list,
writes blocked with banner.
04 — Information Architecture (Parents Module)
- 1. Two zones
- 2. Route table (module-owned; extends
00-shared/05 §4) - 3. Screen hierarchy
- 4. Modal hierarchy (module)
- 5. Screen relationships
- 6. Nested nav
- 7. Quick actions / FAB / context menus
- 8. Deep links (proposed routing table)
- 9. Accessibility of IA
- 10. Peripheral: which nav elements are Parents-owned
Where Parents screens live in the shell (00-shared/05). Two zones: the admin guardians workspace (CRUD + linking) and the parent self-service zone (
(forward-looking)— no "my profile/children" endpoints exist today, see 01_Product_Overview.md OQ-1). All routes derive fromparent.controller.ts(parent.controller.ts:22-60) and the shared shell.
1. Two zones
AppShell (authed)
├─ ADMIN WORKSPACE (role: org_admin / admission staff)
│ /parents parents list (paginated)
│ /parents/:id parent detail (+ linked children)
│ /parents/new create parent profile
│ /parents/:id/edit edit parent profile
│ (sheet) link guardian POST /parents/link/:studentId
│ (dialog) unlink guardian DELETE /parents/link/:linkId
│
└─ PARENT SELF-SERVICE (role: parent — (forward-looking))
/my/children my children + child switcher
/my/profile my guardian profile (edit)
- Admin workspace routes are gated by intended
parent.read/create/update/delete/linkpermissions(planned)(04-Modules/Parents.md:64-70) — not seeded today (permissions.constants.ts); server is JWT-only (parent.controller.ts:23). - Parent self-service zone is reachable for the
parentrole only (role.schema.ts:50-56); requires the(forward-looking)my-profile endpoint.
2. Route table (module-owned; extends 00-shared/05 §4)
| Route | Screen | Auth | Source API |
|---|---|---|---|
/parents | Parents list | JWT (+ parent.read planned) | GET /parents |
/parents/new | Create parent form | JWT (+ parent.create planned) | POST /parents |
/parents/:id | Parent detail + linked children | JWT (+ parent.read planned) | GET /parents/:id, GET /parents/:id/students |
/parents/:id/edit | Edit parent form | JWT (+ parent.update planned) | PATCH /parents/:id |
/parents/link (sheet) | Link guardian ↔ student | JWT (+ parent.link planned) | POST /parents/link/:studentId |
| — (dialog) | Unlink guardian | JWT (+ parent.link planned) | DELETE /parents/link/:linkId |
/my/children | My children + switcher (forward-looking) | parent role | (planned) |
/my/profile | My guardian profile (forward-looking) | parent role | GET/PATCH /parents/:id |
3. Screen hierarchy
- Parents list is the admin hub: →
/parents/new, →/parents/:id(detail) →/parents/:id/edit, detail → link sheet (per child) / unlink dialog. - Student detail (Students module) cross-links to guardians via
GET /parents/link/student/:studentId(parent.controller.ts:53-57) — guardian list embedded in the student screen. - Parent zone:
/my/childrenis the hub; child switcher swaps context;/my/profileis a subpage.
flowchart TD
PL[/parents/] --> PN[/parents/new/]
PL --> PD[/parents/:id/]
PD --> PE[/parents/:id/edit/]
PD --> LS[(link sheet)] --> POST1[POST /parents/link/:studentId]
PD --> UD[(unlink dialog)] --> DEL[DELETE /parents/link/:linkId]
SD[/students/:id/] --> SL[(guardians via GET /parents/link/student/:studentId)]
MC[/my/children/ (forward-looking)/] --> CS[(child switcher)]
MC --> MP[/my/profile/ (forward-looking)/]
4. Modal hierarchy (module)
Following 00-shared/05 §5:
- Snackbar — "Guardian linked", "Link removed", "Profile updated".
- Bottom sheet — link guardian (relationship + flags ≤ 6 controls), student picker.
- Dialog — unlink confirm (destructive-ish), "already linked" warning, primary switch confirm.
- Full-screen page — create/edit parent form (> 3 fields → page,
00-shared/05 §5). - Loader — skeleton on lists; CTA spinner on mutations.
5. Screen relationships
- List → Detail → Edit; Edit returns → Detail (refresh via re-fetch — no cache for
detail,
00-shared/06 §3.3). - Detail → linked children → per-child guardian menu (unlink, set primary via delete+recreate — OQ-4).
- Student detail ↔ parent detail bidirectional navigation.
- Unlink from either side refreshes the other (shared
ParentsCubitcache invalidation).
6. Nested nav
- Tablet/desktop ≥ 840 dp: master-detail —
/parentslist left, detail right (00-shared/05 §3); phone pushes. - Parent zone:
/my/childrensingle pane with switcher bar (no master-detail).
7. Quick actions / FAB / context menus
- Parents list FAB "Add guardian" (extended) →
/parents/new. - Parent row
AppMenu: Edit, Open; linked-child rowAppMenu: Unlink, Set primary. - Student detail: "Add guardian" tonal button → link sheet.
- Keyboard (desktop):
Non/parents= new;Esccloses sheets/dialogs (00-shared/08 §2).
8. Deep links (proposed routing table)
| Pattern | Target | Note |
|---|---|---|
studylyon://parents/:id | Parent detail | admin |
studylyon://students/:id | Student detail | parent: only if linked (forward-looking) |
studylyon://my/children | My children | parent role |
9. Accessibility of IA
- One
headlineMediumper screen; forms ordered identity → role → flags; focus begins at first field (09_Accessibility_Baseline.md §11). - Link sheet: single semantics group per guardian row.
10. Peripheral: which nav elements are Parents-owned
- Admin: the
/parentsworkspace only. Parent:/my/children+/my/profile— both must register in the shared shell's role-gated destination map (00-shared/05 §2) and be hidden + unroutable for other roles (00-shared/05 §1).
05 — Screen Inventory (Parents Module)
- 1. Parents List (
/parents) - 2. Parent Detail (
/parents/:id) - 3. Create / Edit Parent Form (
/parents/new,/parents/:id/edit) - 4. Link Guardian Sheet (from detail or student screen)
- 5. Unlink Dialog
- 6. My Children (
/my/children)(forward-looking) - 7. Child Switcher (component, embedded in
/my/childrenand child context screens) - 8. My Profile (
/my/profile)(forward-looking) - Shared components used
- Analytics events (proposed)
Every screen of the Parents module: intent, route, composition, states, permissions, platform behavior, events. Authoritative components in 00-shared/03; module-specific components in 07_Component_Library.md.
Legend — States: idle / loading / success / empty / error(offline, 4xx, 5xx) /
permission. Analytics events {module}.{screen}.{action} (proposed; SDK open —
00-shared/10 §8).
1. Parents List (/parents)
| Field | Detail |
|---|---|
| Purpose | Paginated directory of guardian profiles |
| Source | GET /parents → data: ParentDocument[], meta:{page,limit,totalItems,totalPages,hasNext,hasPrevious} (parent.service.ts:55-67, pagination-query.dto.ts:32-39) |
| Entry / Exit | shell → list; → detail, → /parents/new |
| Widgets | AppSearchBar — server ignores q (parent.service.ts:58-61) → treat as client-side filter or omit (OQ-7); AppListTile rows (identity from joined User), AppSkeleton(list), AppEmptyState ("No guardians yet"), AppErrorState, RefreshIndicator |
| FAB | "Add guardian" (extended, Icons.person_add) → /parents/new |
| Pagination | infinite scroll via meta.hasNext (00-shared/06 §3.2); no sort param honored (OQ-7) |
| Row actions | AppMenu: Open, Edit |
| States | loading skeleton; empty; error 5xx generic + requestId; offline last-good cache + banner |
| Analytics | parents.list.{view,search,open,create_tap} |
| A11y | row semantics "Guardian |
| Adaptive | phone single column; tablet 2-column; desktop master-detail ≥ 840 dp |
2. Parent Detail (/parents/:id)
| Field | Detail |
|---|---|
| Purpose | Full guardian profile + all linked children with per-link metadata |
| Source | GET /parents/:id (404 "Parent not found." parent.service.ts:51) + GET /parents/:id/students → raw link docs (parent.service.ts:69-72) |
| Entry / Exit | list → detail; → edit; → link sheet |
| Composition | Header: AppAvatar (from User.avatarFileId/name), display name (join users), chips (occupation, company); sections: Emergency & pickup (profile flags), Linked children (per child: relationship chip, primary badge, pickup/financial/priority line, AppMenu: Unlink, Set primary) |
| Join note | Link docs expose only studentId/parentId ObjectIds (student-parent-link.schema.ts:18-25) → client joins GET /students/:id per child (OQ-10) |
| 404 state | AppErrorState "Guardian not found" + back |
| Pull-to-refresh | yes |
| Analytics | parents.detail.{view,edit_tap,link_tap,unlink_tap} |
3. Create / Edit Parent Form (/parents/new, /parents/:id/edit)
| Field | Detail |
|---|---|
| Purpose | Create or update a guardian profile |
| Source | POST /parents (create-parent.dto.ts), PATCH /parents/:id (update-parent.dto.ts) |
| Create-only field | userId (required @IsMongoId, create-parent.dto.ts:5-7) — picker over GET /users?q= (users.service.ts:90-115); not editable after create (update-parent.dto.ts has no userId) |
| Fields | occupation, company, annualIncome (number), relationshipNotes, emergencyContactPriority (number), pickupAuthorization (switch, default false parent.schema.ts:28) |
| Duplicate | 409 → inline banner + "open existing profile" (parent.service.ts:32-34) |
| States | idle/loading/error/saving; 400 field details mapped |
| Analytics | parents.form.{open,submit,success,duplicate,error} |
4. Link Guardian Sheet (from detail or student screen)
| Field | Detail |
|---|---|
| Purpose | Create one student_parent_links row |
| Source | POST /parents/link/:studentId (link-parent.dto.ts, student-parent-link.service.ts:16-32) |
| Composition | student picker (search by admission number/name) or preselected; AppDropdown relationship (mother/father/guardian/grandparent/relative/foster_parent — enum student-parent-link.schema.ts:7-14); AppSwitch isPrimaryGuardian; AppSwitch pickupAllowed (default true, student-parent-link.schema.ts:33-34); AppSwitch financialResponsibility; number field emergencyPriority |
| Warnings | "Already linked" if the pair exists in loaded links (client pre-check, OQ-2); "This student already has a primary guardian — replace?" (OQ-5) |
| Errors | 404 student (only when server validates — today it doesn't, OQ-3); 400 validation |
| Analytics | parents.link.{open,submit,success,warn_duplicate} |
5. Unlink Dialog
| Field | Detail |
|---|---|
| Purpose | Confirm removing a parent–student relationship |
| Source | DELETE /parents/link/:linkId → 404 "Link not found." (student-parent-link.service.ts:38-41) |
| Copy | "Unlink 04-Modules/Parents.md:58) |
| Primary case | If link isPrimaryGuardian → warn "This child will have no primary guardian." (OQ-4) |
| Behavior | destructive-style confirm (error CTA, heavyImpact); server-first (no optimistic) |
6. My Children (/my/children) (forward-looking)
| Field | Detail |
|---|---|
| Purpose | Parent home: enumerate own linked children only (privacy boundary USER_PERSONAS.md:48-53) |
| Source | No endpoint today (OQ-1): needs (planned) GET /parents/me (or userId filter) + links; then GET /students/:id join per child |
| Composition | ChildSwitcherBar (avatars row), selected child summary card (name, class/grade via student.schema.ts:33-39), entry points to attendance/results/fees (other modules, student.read) |
| Empty | AppEmptyState "No linked children — contact the school office" |
| Offline | cached last-good children list |
7. Child Switcher (component, embedded in /my/children and child context screens)
| Field | Detail |
|---|---|
| Purpose | Multi-child households switch context without leaving the screen |
| State | ChildSwitcherCubit (selectedChildId persisted in memory; 13_State_Management.md §3) |
| Behavior | horizontal avatar chips, selected = primaryContainer; all child-context modules read selected child |
| A11y | each chip: "Child: Semantics(toggled:) |
8. My Profile (/my/profile) (forward-looking)
| Field | Detail |
|---|---|
| Purpose | Parent edits own guardian flags (occupation, emergency priority, pickup) |
| Source | GET /parents/:id + PATCH /parents/:id (self-owned id resolved via (planned) my-profile endpoint) |
| Read-only | relationship/primary/pickup-per-child (link-level, no link PATCH endpoint — OQ-4); identity fields (from users) |
Shared components used
AppListTile, AppCard, AppAvatar, AppChips, AppBadge, AppSwitch, AppDropdown,
AppTextField, AppSearchBar, AppDialog, AppBottomSheet, AppMenu, AppSkeleton,
AppEmptyState, AppErrorState, AppOfflineBanner, AppFAB, AppSnackbar,
AppSectionHeader, AppInfoRow. Module-specific: ParentListTile, GuardianCard,
LinkedChildCard, RelationshipChip, PrimaryGuardianBadge, ChildSwitcherBar
(07_Component_Library.md).
Analytics events (proposed)
parents.list.{view,search,open,create_tap}, parents.detail.{view,edit_tap,link_tap,unlink_tap},
parents.form.{open,submit,success,duplicate,error}, parents.link.{open,submit,success,warn_duplicate},
parents.unlink.{confirm,success,404}, parents.my.children.{view,switch_child} (all proposed).
06 — Screen Specifications (Parents Module)
- 1. Parents List
/parents - 2. Parent Detail
/parents/:id - 3. Create / Edit Parent Form
/parents/new,/parents/:id/edit - 4. Link Guardian Sheet (from detail / student screen)
- 5. Unlink Dialog
- 6. My Children
/my/children(forward-looking) - 7. Child Switcher (component)
- 8. My Profile
/my/profile(forward-looking) - Shared micro-interaction & motion notes (all screens)
Production-grade specs for each Parents screen: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens:
Sp= 00-shared/02 §3,Ty= typography §2,El= elevation §5,R= radius §4,Mo= motion §6. Components reference 00-shared/03 + 07_Component_Library.md.
1. Parents List /parents
Chrome: standard shell AppBar (headlineMedium "Guardians", subtitle = count from
meta.totalItems); horizontal padding 16 phone / 24 tablet+; AppBar elevation e-1.
Layout (phone):
AppBar "Guardians" headlineMedium
AppSearchBar (client-side filter only — server ignores q, OQ-7)
[AppListTile ×N] ParentListTile: AppAvatar 40, name titleMedium,
subtitle "occupation · company",
trailing children-count badge
infinite scroll footer "End of list" bodySmall / spinner
FAB "Add guardian" FloatingActionButton.extended, Icons.person_add
States:
- initial →
AppSkeleton(list)(8 rows, shimmerm-slow; reduced-motion static tint). - success+data → list;
RefreshIndicator(bypasses cache). - success+empty →
AppEmptyStateIcons.groups, "No guardians yet", action "Add first guardian". - error →
AppErrorState(code); 5xx generic + requestId hint; Retry re-fetches page 1. - offline →
AppOfflineBannertop; stale-while-revalidate from last-good cache (00-shared/06 §3.3). - permission
(planned)→ 403 screen; today any JWT can list (OQ-8).
Pagination: ParentsCubit.loadMore() on scroll end when meta.hasNext
(pagination-query.dto.ts:32-39); bottom spinner fade-in m-instant; page increments
1-based; limit 20 default (pagination-query.dto.ts:19).
Motion: row entrance stagger m-entrance (40 ms); FAB hide on scroll-down, show on
scroll-up (00-shared/03 D).
A11y: search results announced via live region; row semantics "Guardian
Tablet/desktop: ≥ 840 dp master-detail — list pane 320–360 dp, detail in right pane
(see §2); hover row tint m-fast; N key = new guardian; Esc clears search.
Dark/light: token-only (surface, surfaceContainerLow, onSurfaceVariant);
children-count badge primaryContainer.
2. Parent Detail /parents/:id
Chrome: AppBar title = display name (from joined User), back to list; body scrolls.
Layout (phone):
Header card AppCard e-1
AppAvatar 64 (User.avatarFileId / initials)
name titleLarge
occupation · company bodyMedium onSurfaceVariant
AppInfoRow: annual income, relationship notes, emergency priority, pickup
actions: [Edit] OutlinedButton → /parents/:id/edit
Section "Linked children" AppSectionHeader (+ "Link" TextButton)
LinkedChildCard ×N (see component spec §2 of 07)
empty → AppEmptyState "No linked children — Link a student"
States:
- loading →
AppSkeletonheader card + 2 child cards. - 404 →
AppErrorState"Guardian not found" (parent.service.ts:51) + back button. - error →
AppErrorState(code)+ Retry. - children load (
GET /parents/:id/students) is a second call (parent.service.ts:69-72): header renders first, children section shows inlineAppSkeletonrows; failure in children section only → inlineAppErrorState(compact).
Linked children rendering (join, OQ-10): each card fetches GET /students/:id
(client-side join). While joining: AppSkeleton inline. On child 404 (deleted) → card
shows "student no longer active" state with unlink action still available.
Per-child menu (AppMenu):
- "Set as primary" → shown when
isPrimaryGuardianis false; action = delete old primary link + recreate this one (no link PATCH endpoint — OQ-4); confirm dialog explains the two-step. - "Unlink" →
AppDialog(§5) →DELETE /parents/link/:linkId→ row fade-outm-base+ snackbar "Guardian unlinked" (student-parent-link.service.ts:38-41).
Guardian-relationship presentation: RelationshipChip per child + PrimaryGuardianBadge
"Primary" (primaryContainer tonal) when isPrimaryGuardian (student-parent-link.schema.ts:27-29).
Motion: hero AppAvatar list→detail m-slow; card entrances m-entrance stagger.
A11y: sections use Semantics(headers:); menu items labeled; badge announced
"Primary guardian".
Tablet/desktop: master-detail right pane; edit opens as overlay page in pane.
3. Create / Edit Parent Form /parents/new, /parents/:id/edit
Chrome: full-screen page (00-shared/05 §5 — > 3 fields); AppBar title
"Add guardian" / "Edit guardian"; scroll + keyboard avoidance.
Create layout (phone):
User picker (create only) AppTextField(read-only) + search sheet over GET /users?q=
— shows User.displayName + email (users.service.ts:90-115)
occupation AppTextField
company AppTextField
annualIncome AppTextField (number, currency locale)
relationshipNotes AppTextField multiline
emergencyContactPriority AppTextField (number 0-99)
pickupAuthorization AppSwitch ListTile "Authorized to pick up child"
(default false — parent.schema.ts:28)
CTA "Save guardian" FilledButton fullWidth h 48
Edit layout: same minus user picker (UpdateParentDto has no userId,
update-parent.dto.ts:4-34).
Validation (client, mirrors DTO):
userIdrequired + MongoId (create-parent.dto.ts:6-7) — client always passes a picked user's id.- occupation/company/relationshipNotes strings (
IsStringoptional). - annualIncome / emergencyContactPriority numbers (client:
NumberFormatparsing; server has no bounds —annualIncomeunbounded,emergencyContactPriorityunbounded — client caps 0–9999, propose serverMin/Max). - pickupAuthorization boolean.
- Server 400
VALIDATION_ERRORdetails map to fields (http-exception.filter.ts:104-107).
States:
- saving → CTA spinner, fields disabled, no double-submit.
- 409
DUPLICATE_RESOURCE(create) → inlineAppBanner(error)"A guardian profile already exists for this user." + button "Open existing profile" →/parents/:id(parent.service.ts:32-34). - 404 (edit) →
AppErrorState"Parent not found." (parent.service.ts:77). - success → pop to detail; snackbar "Guardian saved"; audit event
ParentUpdated(event-queue-map.ts:38) orParentCreated→ in-app (event-queue-map.ts:37).
A11y: labels visible + linked; errors announced + focus first invalid
(09 §10); autofill hints organization for company (desktop).
Motion: route push m-base; error slide-in m-fast.
Tablet/desktop: centered card ≤ 560 dp; Enter submits; Tab order = visual.
4. Link Guardian Sheet (from detail / student screen)
Chrome: AppBottomSheet (half → full height on phone when keyboard); DragHandle;
title "Link guardian to student"; scrollable body.
Layout:
Student (if not preselected) AppSearchBar → result rows (admissionNumber + name)
selection replaces field with chosen chip + change
relationship AppDropdown 6 values (enum student-parent-link.schema.ts:7-14)
isPrimaryGuardian AppSwitch "Primary guardian" (default false)
pickupAllowed AppSwitch "Allowed to pick up" (default true — schema:33-34)
financialResponsibility AppSwitch "Financially responsible" (default false)
emergencyPriority AppTextField number (0-99)
CTA "Link guardian" FilledButton fullWidth
Warnings (client-side pre-checks over loaded links):
- Pair already linked →
AppBanner(warning)"Already linked — saving creates a duplicate (server allows it, OQ-2)" + disable CTA or "Link anyway". - Student already has a primary and
isPrimaryGuardiantoggled on →AppBanner(warning)"This student already has a primary guardian. Two primaries will exist." (OQ-5).
States: submitting → CTA spinner; 400 → field errors; 404 student → banner (only if
server ever enforces — today findById result is discarded, OQ-3); success → sheet pops
with link doc → children section refreshes + snackbar "Guardian linked" (lightImpact).
A11y: sheet = dialog semantics; each switch Semantics(toggled:); dropdown role
combobox.
Motion: sheet slide-up m-base; warning banner expand AnimatedSize.
5. Unlink Dialog
Chrome: AppDialog centered card; title "Unlink guardian?"; body:
"Remove 04-Modules/Parents.md:58).
- Primary warning line (error container,
erroricon) whenisPrimaryGuardian: "This child will have no primary guardian after unlinking." (OQ-4). - Actions:
TextButtonCancel /FilledButtondestructive "Unlink". - Behavior: server-first (no optimistic — irreversible);
heavyImpacton confirm; success → row removedm-basefade + snackbar; 404 → treat-as-removed (student-parent-link.service.ts:40).
6. My Children /my/children (forward-looking)
Chrome: shell AppBar "My children"; body = ChildSwitcherBar + selected child
summary.
Composition:
ChildSwitcherBar horizontal chips (avatar + name), selected primaryContainer
ChildSummaryCard name, grade/class/section (student.schema.ts:33-39),
admissionNumber mono
Section entries Attendance / Results / Fees (other modules; student.read)
AppBar action AppAvatar → profile menu → /my/profile
States: loading skeleton; empty AppEmptyState "No linked children"; 404/error →
AppErrorState; offline → cached list + banner.
Blocker note: depends on (planned) my-profile/children endpoint (OQ-1) — the screen
spec is frozen until then; do not build against a client-side workaround.
7. Child Switcher (component)
Spec: ChildSwitcherBar — horizontal SingleChildScrollView of FilterChip-style
avatar chips (40 dp, initials/avatar, name below, primaryContainer selected state);
ChildSwitcherCubit.selectedChildId drives all child-context modules; switch animates
AnimatedSwitcher fade m-fast; a11y chips Semantics(button, toggled); announced
"Switched to
8. My Profile /my/profile (forward-looking)
Same form chrome as §3 (edit mode) with read-only header (identity from users,
relationship per child from links). PATCH /parents/:id on save
(parent.service.ts:74-87). Blocks: no self-id resolution (OQ-1) — frozen spec.
Shared micro-interaction & motion notes (all screens)
- Route transitions
m-base; snackbar success < 3 s, error ≥ 4 s (00-shared/03 A). - Cards
e-1; sheetse-5; dialogse-4; FABe-3,r-full. - Touch targets ≥ 48 dp; rows ≥ 56 dp (
02 §9). - Safe areas respected; landscape lists scroll.
- Reduced motion: fades only, no stagger/shake (
08 §4).
07 — Component Library (Parents Module)
- 1. ParentListTile (list row)
- 2. LinkedChildCard (detail-screen child row)
- 3. RelationshipChip
- 4. PrimaryGuardianBadge
- 5. EmergencyFlagsRow (detail meta line)
- 6. UserPickerField (create-form)
- 7. ChildSwitcherBar
- 8. GuardianSummaryCard (student-detail embed)
- 9. Flutter widget map (module)
- 10. Golden tests (base 00-shared/03 G)
Module-specific components built from shared
App*primitives (00-shared/03). Props, states, animation, a11y, tokens, Flutter mapping. Reuse over new — only components missing from the shared library are defined here. All data fields map exactly toparent.schema.ts,student-parent-link.schema.ts, anduser.schema.ts.
1. ParentListTile (list row)
| Field | Spec |
|---|---|
| Purpose | One guardian in /parents list |
| Props | parentId, displayName, photoUrl?, occupation?, company?, linkedChildrenCount, onTap, menu? |
| Rendering | AppListTile 56+: AppAvatar 40 leading; title = displayName titleMedium (from joined User); subtitle = "occupation · company" bodyMedium onSurfaceVariant (omit empties); trailing = AppBadge "N children" primaryContainer + AppMenu (Open, Edit) |
| Data source | GET /parents + GET /parents/:id/students count (client join, OQ-10); children count can be lazy-fetched on demand |
| a11y | Semantics(button, label: "Guardian <name>, <N> linked children") |
| Tokens | surface, e-0; avatar initials primaryContainer |
| Flutter | ListTile in Card(elevation: 0) with MenuAnchor |
2. LinkedChildCard (detail-screen child row)
| Field | Spec |
|---|---|
| Purpose | One parent–student link with relationship metadata |
| Props | link (link doc: relationship, isPrimaryGuardian, financialResponsibility, pickupAllowed, emergencyPriority, student-parent-link.schema.ts:18-40), student (joined or loading), onTap (→ student detail), onUnlink, onSetPrimary, isPrimaryWarning |
| Rendering | AppCard e-1: leading AppAvatar (student), title student name + mono admissionNumber, subtitle line: RelationshipChip + PrimaryGuardianBadge; meta line bodySmall: "Pickup ✓/✗ · Financial ✓/✗ · Priority N" (icon+label, never color-only — 09 §9); trailing AppMenu: "Set as primary" (when not primary), "Unlink" |
| States | student == null → inline AppSkeleton; student 404 → "Student no longer active" (bodySmall warning) with unlink still available |
| a11y | card button + menu; badge text "Primary guardian" |
| Flutter | Card + ListTile + Row of Icon+Text; MenuAnchor |
3. RelationshipChip
| Field | Spec |
|---|---|
| Purpose | Display the relationship enum value |
| Props | relationship (one of `mother |
| Mapping | label = i18n key parent.relationship.<value>; icon map: mother → female, father → male, guardian → verified_user, grandparent → elderly, relative → family_restroom, foster_parent → home_work |
| Tokens | AppChips read-only: surfaceVariant fill, labelMedium |
| a11y | chip label + tooltip; Semantics(label: "Relationship: <value>") |
| Flutter | Chip (disabled) with Icon |
4. PrimaryGuardianBadge
| Field | Spec |
|---|---|
| Purpose | Mark isPrimaryGuardian === true per link |
| Props | active (bool), compact? |
| Rendering | active → AppBadge tonal primaryContainer, icon star + "Primary"; inactive → nothing (or outline "Secondary" only in edit contexts) |
| a11y | announced "Primary guardian" when active |
| Flutter | Badge/custom Container with Icon + Text |
5. EmergencyFlagsRow (detail meta line)
| Field | Spec |
|---|---|
| Purpose | Compact per-link flags line: pickup, financial responsibility, priority |
| Props | pickupAllowed, financialResponsibility, emergencyPriority |
| Rendering | Row of icon+label pairs bodySmall: directions_walk ✓/✗ "Pickup", payments "Billing" (only when true), priority_high "N" (when > 0) |
| a11y | each pair Semantics(label); never color-only (icon + text) — 09 §9 |
| Flutter | Wrap of Icon+Text |
6. UserPickerField (create-form)
| Field | Spec |
|---|---|
| Purpose | Choose the User a new parent profile belongs to |
| Props | selectedUser?, onPicked(UserSummary), enabled |
| Behavior | read-only AppTextField + Icons.search → opens AppBottomSheet with AppSearchBar over GET /users?q= (users.service.ts:90-115); results = displayName + email; picking pops sheet with the user; clear button available |
| a11y | field label "User account"; results list live region |
| Flutter | InkWell field + showModalBottomSheet |
7. ChildSwitcherBar
| Field | Spec |
|---|---|
| Purpose | Multi-child context switcher (parent zone, (forward-looking)) |
| Props | children[{id, name, avatarUrl?}], selectedId, onChanged(id), loading? |
| Rendering | horizontal scrollable row of avatar chips (40 dp, initials fallback); selected = primaryContainer fill + primary border; AppSkeleton when loading |
| Behavior | tap → ChildSwitcherCubit.switchChild(id); announce "Switched to |
| a11y | chips Semantics(button, toggled: selected) |
| Flutter | SingleChildScrollView + custom FilterChip-style widgets |
8. GuardianSummaryCard (student-detail embed)
| Field | Spec |
|---|---|
| Purpose | Guardian list inside student detail (reverse direction) |
| Props | links, parents (joined), onOpenParent(id), onAdd |
| Rendering | AppCard section: per link — RelationshipChip + name + PrimaryGuardianBadge; TextButton "Add guardian" → link sheet; empty → "No guardians linked" |
| Source | GET /parents/link/student/:studentId (parent.controller.ts:53-57) + parent join |
| Flutter | Card + ListTile rows |
9. Flutter widget map (module)
| Component | Flutter widgets |
|---|---|
| ParentListTile | ListTile + CircleAvatar + Badge + MenuAnchor |
| LinkedChildCard | Card + ListTile + Chip + MenuAnchor |
| RelationshipChip | Chip (disabled) |
| PrimaryGuardianBadge | custom Container + Icon |
| EmergencyFlagsRow | Wrap of Icon + Text |
| UserPickerField | InkWell + TextField(readOnly) + bottom sheet |
| ChildSwitcherBar | ListView horizontal of chips |
| GuardianSummaryCard | Card + ListTile |
10. Golden tests (base 00-shared/03 G)
Every module component: golden at phone/tablet/desktop + dark mode; state permutations
for LinkedChildCard (primary/not, pickup on/off, student-missing) and
ChildSwitcherBar (1 child, 4 children, loading).
08 — Form Specifications (Parents Module)
- 1. Create Parent —
POST /parents(create-parent.dto.ts) - 2. Update Parent —
PATCH /parents/:id(update-parent.dto.ts) - 3. Link Parent —
POST /parents/link/:studentId(link-parent.dto.ts) - 4. Form-level rules (all)
- Client-side error priority (all forms)
Every form field-by-field. Validation mirrors class-validator decorators exactly from
src/modules/parents/dto/*.ts; server failures come back asVALIDATION_ERROR(400) with per-fielddetails(http-exception.filter.ts:104-107). Client validates inline first; server 400 shadows client.
1. Create Parent — POST /parents (create-parent.dto.ts)
| # | Field | Type | Required | Server decorator | Notes / UX |
|---|---|---|---|---|---|
| 1 | userId | ObjectId picker | ✓ | @IsMongoId() (create-parent.dto.ts:6-7) | hidden behind UserPickerField (07 §6); the only required field |
| 2 | occupation | text | no | @IsOptional() @IsString() (:10-12) | autofill organization-title |
| 3 | company | text | no | @IsOptional() @IsString() (:14-16) | autofill organization |
| 4 | annualIncome | number (currency) | no | @IsOptional() no type decorator (:19-21) | client NumberFormat parse; display as currency; no Min/Max server-side → client bounds 0–1e9 (propose server bounds) |
| 5 | relationshipNotes | text (multiline) | no | @IsOptional() @IsString() (:23-26) | maxLength 500 (client) |
| 6 | emergencyContactPriority | number (0–99) | no | @IsOptional() no type decorator (:28-30) | default 0 (parent.schema.ts:24-25); client bounds 0–99; hint "lower = called first" |
| 7 | pickupAuthorization | switch | no | @IsOptional() no type decorator (:32-34) | default false (parent.schema.ts:28); "Authorized to pick up child" |
Submit → loading → server:
- 201/200
data= parent doc (id, userId, flags,createdAt/updatedAt/versionfromBaseSchema,base.schema.ts:8-35). - 409
DUPLICATE_RESOURCE→ banner + "Open existing profile" (parent.service.ts:32-34). - 400 details → per-field (
http-exception.filter.ts:104-107).
Note on DTO typeless numerics/booleans:
annualIncome,emergencyContactPriority,pickupAuthorizationinCreateParentDtohave no@IsNumber()/@IsBoolean()decorators (create-parent.dto.ts:18-34) — strings pass validation and would be stored. Client must send proper JSON types.
2. Update Parent — PATCH /parents/:id (update-parent.dto.ts)
Same field set as §1 minus userId (not updatable) plus metadata:
| # | Field | Type | Required | Server decorator |
|---|---|---|---|---|
| 8 | occupation | text | no | @IsOptional() @IsString() (update-parent.dto.ts:6-8) |
| 9 | company | text | no | @IsOptional() @IsString() (:10-12) |
| 10 | annualIncome | number | no | @IsOptional() (:14-16) |
| 11 | relationshipNotes | text | no | @IsOptional() @IsString() (:18-20) |
| 12 | emergencyContactPriority | number | no | @IsOptional() (:22-24) |
| 13 | pickupAuthorization | boolean | no | @IsOptional() (:26-28) |
| 14 | metadata | object | no | @IsOptional() (:32-34) |
PATCH semantics: partial — only sent keys are set ($set, parent.service.ts:76);
version incremented by repository (base.repository.ts:62-65). Client sends only
changed fields. 404 "Parent not found." (parent.service.ts:77).
3. Link Parent — POST /parents/link/:studentId (link-parent.dto.ts)
| # | Field | Type | Required | Server decorator | Notes / UX |
|---|---|---|---|---|---|
| 15 | parentId | ObjectId | ✓ | @IsMongoId() (link-parent.dto.ts:6-7) | fixed by context (from parent detail) |
| 16 | relationship | dropdown | ✓ | @IsString() (:19-20) — free string, no enum | must send exactly one of mother|father|guardian|grandparent|relative|foster_parent (student-parent-link.schema.ts:7-14); client dropdown guarantees; invalid value → schema rejection → 500 (OQ-6) |
| 17 | isPrimaryGuardian | switch | no | @IsOptional() (:23-24) | default false; warn when another primary exists (OQ-5) |
| 18 | financialResponsibility | switch | no | @IsOptional() (:26-28) | default false (student-parent-link.schema.ts:31) |
| 19 | pickupAllowed | switch | no | @IsOptional() (:30-32) | default true (student-parent-link.schema.ts:33-34) — opposite of profile pickupAuthorization |
| 20 | emergencyPriority | number (0–99) | no | @IsOptional() (:34-36) | default 0 (student-parent-link.schema.ts:36-37) |
studentId comes from the route param, never the body (student-parent-link.service.ts:16-32).
Client-side pre-checks before submit:
- pair
(parentId, studentId)already in loaded links → warn duplicate (OQ-2). isPrimaryGuardiantrue while another link for this student is primary → warn (OQ-5).- student picker required when opened from parent detail; parent picker required when opened from student detail.
Errors: 400 validation; 404 student (only if server later enforces — OQ-3); success →
link doc in data.
4. Form-level rules (all)
- Double-submit disabled while pending (00-shared/08 §6).
- No optimistic writes for any parents mutation (links are consequential; server truth), except none — all server-first (00-shared/06 §3.5).
- Undo: none for unlink (irreversible server-side); snackbar confirm only.
- Keyboard:
.nextsequence, last.done;Entersubmits (desktop). - Autofill hints on identity-ish fields (occupation/company).
- Error copy: envelope
messagefor business 4xx (409/404); codes for the rest (07_API_Conventions.md §11).
Client-side error priority (all forms)
- 400
VALIDATION_ERROR→ field. - 404
RESOURCE_NOT_FOUND→ banner + context action. - 409
DUPLICATE_RESOURCE→ inline + link to existing. - 422
BUSINESS_RULE_VIOLATION→ banner (not produced by this module today). - 429
RATE_LIMITED→ countdown. - 5xx →
AppErrorState+ requestId.
09 — User Behaviour (Parents Module)
- 1. Expected behaviour (server-contract-driven)
- 2. Common mistakes & countermeasures
- 3. Power & new users
- 4. Mobile & a11y users
- 5. Interrupted sessions & retries
- 6. Abandonment
- 7. Frustration points → UX treatments (summary)
- 8. Cross-device & conflict
- 9. Adoption path (holistic)
Expected behaviour (derived from server semantics + shared interaction rules), common mistakes users make, and UX countermeasures. Purpose: playbook for the UI designer so the flow never surprises.
1. Expected behaviour (server-contract-driven)
- A guardian profile belongs to one user, forever.
tenantId+userIdunique index (parent.schema.ts:36); a second profile for the same user → 409. UI must pre-check via the user picker ("this user already has a guardian profile"). - The same person can be linked to many children and each child can have many
guardians (M2M,
student-parent-link.schema.ts:18-25) — no "one parent one child" assumptions anywhere in the UI. - The profile is identity-light: name/email/phone come from
users(parent.schema.ts:8-32,COLLECTIONS.md §2.2). UI never offers to edit name/email in the parent form. pickupAllowed(link) defaults true;pickupAuthorization(profile) defaults false (student-parent-link.schema.ts:33-34,parent.schema.ts:27-28) — UI must present them as separate concerns ("authorized at office level" vs "allowed per child") and not assume they match.- Unlink is safe by design: deleting a link never deletes the parent
(
04-Modules/Parents.md:58); the parent row persists with zero links. - Delete is soft:
DELETE /parents/:idsetsisDeleted(base.repository.ts:68-74); lists exclude it (base.repository.ts:26-28). No hard delete in the API. - Audit trail exists:
ParentUpdated/ParentDeleted→audit-writejobs (event-queue-map.ts:38-39) — every profile change is traceable.
2. Common mistakes & countermeasures
| Mistake | Related | UX countermeasure |
|---|---|---|
| Creating a duplicate profile for the same user | create | user picker warns when the chosen user already has a profile (409 otherwise, parent.service.ts:32-34) |
| Linking the same guardian to the same child twice | link | client pre-check over loaded links + warning (server allows duplicates, OQ-2) |
| Setting two primary guardians for one child | link | warning when toggling primary while another exists (OQ-5) |
| Unlinking the primary guardian by accident | unlink | dialog warning "child will have no primary guardian" + confirm (OQ-4) |
| Typing an invalid relationship value | link | dropdown only, enum values (student-parent-link.schema.ts:7-14); free text would 500 (OQ-6) |
| Searching the parents list expecting server search | list | q is ignored server-side (parent.service.ts:58-61) → client-side filter only, or hide search (OQ-7) |
Expecting userId to be editable | edit | no userId in UpdateParentDto (update-parent.dto.ts:4-34) — field hidden, doc note "transfer ownership = create new + link" |
| Expecting per-child flags editable after link | link | no link PATCH endpoint — per-child flags are set at create only; change = unlink + relink (OQ-4). UI must offer "recreate link" flow with copy preserved |
| Deleting a parent expecting links to vanish | delete | soft delete leaves links (parent.service.ts:89-100) — UI copy: "Links are kept; children will show the guardian as inactive" (or propose cascade) |
3. Power & new users
- Power user (admission staff): bulk season — keyboard
Nnew guardian, fast user picker, tab-complete student admission numbers; link sheet remembers last relationship per user (session preference, client-side). - New user (admin): first-run — empty list →
AppEmptyStateaction "Add guardian"; tooltip "create the user first (Users → Add), then attach the guardian profile".
4. Mobile & a11y users
- One-thumb phone: single primary CTA per screen; 48 dp targets; switches ≥ 48 row.
- TalkBack/VoiceOver: link sheet fully walkable; chips announce relationship;
switcher announces selected child (
09_Accessibility_Baseline.md §7). - Dynamic type 200%: child cards reflow; EmergencyFlagsRow wraps; no fixed widths.
- Reduced motion: no stagger on lists; error = color+icon+text.
5. Interrupted sessions & retries
- App killed mid-link: sheet state lost; parent/student context retained by route — re-open sheet with preselected context.
- Mid-submit network loss: CTA stays pending; on reconnect user must retap (no silent auto-retry — write is not idempotent, OQ-2 duplicates possible).
- 5xx after submit: generic error + requestId; form state preserved; no double row.
- Offline: lists from last-good cache + banner; all writes blocked.
6. Abandonment
- Create form abandoned mid-way: nothing saved; user picker selection lost (server has no drafts).
- Link sheet abandoned: nothing saved — no partial links (link create is atomic).
- Unlink dialog abandoned: nothing happens (soft-delete only on confirm).
7. Frustration points → UX treatments (summary)
| Frustration | Treatment |
|---|---|
| "Which child does this guardian belong to?" | detail groups children with relationship chips + primary badge at a glance |
| "Why can't I change the relationship after linking?" | recreate-link flow in menu with prefilled values; explanatory snackbar |
| "The guardian shows twice for the same child" | duplicate link (server allows) → surface a "Merge/remove duplicate" action that unlinks extras |
| "No primary guardian!" | primary warning on unlink + empty-state hint on student guardian list |
| "This child's data looks wrong" | cross-tenant reads impossible (base.repository.ts:20-30) — any wrongness is a bug, not UI |
8. Cross-device & conflict
- Two admins edit the same parent concurrently → last-write-wins with
versionbump (base.repository.ts:62-65); no conflict signal to client — UI treats PATCH as absolute (server returned doc is truth). - Unlink on one device while another shows the child → next refresh hides it; 404 on
delete → treat-as-removed (
student-parent-link.service.ts:40).
9. Adoption path (holistic)
- P0: admin CRUD + link/unlink + primary badge (all server-supported today).
- P1
(forward-looking): parent self-service (my children, child switcher, my profile) — blocked on my-profile endpoint (OQ-1). - P2
(planned): RBACparent.*permissions (04-Modules/Parents.md:64-70), duplicate link prevention, unique primary enforcement, link PATCH endpoint, cascade policies.
10 — Interaction Specification (Parents Module)
- 1. Phone / touch
- 2. Keyboard / desktop / mouse
- 3. Haptics (mobile)
- 4. Motion language (module extension of 08 §4)
- 5. Micro-interactions catalogue
- 6. Frustration controls (shared 08 §6 + module)
- 7. Special: picker & deep-link interactions
- 8. Validation of interaction to server semantics
Module-specific interactions layered on shared 00-shared/08. Covers every input modality on phone/tablet/desktop.
1. Phone / touch
| Gesture | Behaviour | Where |
|---|---|---|
| Tap | open parent/child, submit forms, toggle switches, open menus | all |
| Tap on CTA | submitting-aware disabled re-tap (00-shared/08 §6) | forms, sheets |
| Long-press | AppMenu on list rows / child cards | parents list, linked children |
| Double-tap | none | — |
| Swipe left/right | no destructive swipe on links (irreversible server-side) — keep confirm | — |
| Pull down | refresh lists (bypasses cache) | parents list, detail, my children |
| Pinch/drag | none | — |
| Hardware back | pop sheet/dialog first; then previous screen; on root → shell | all |
| Edge swipe | drawer (shell) | — |
| Haptics | success lightImpact; link/unlink confirm heavyImpact; menu open selectionClick; error mediumImpact (08 §3) | mutations |
2. Keyboard / desktop / mouse
| Input | Behaviour |
|---|---|
| Tab | form order = visual order; focus ring primary (2 dp) |
| Enter | submits active form (create/edit/link); confirms focused dialog button |
| Esc | closes sheet/dialog/menu; clears search; focus returns to trigger |
| N | new guardian (parents list) |
| Ctrl/Cmd+Enter | submit from text areas |
| Arrows | navigate user/student picker results |
| Right-click | context menu (same as long-press) |
| Hover | row tint m-fast, tooltip after 500 ms; card elevation hint |
| Focus | logical FocusTraversalGroup; modal sheets trap focus |
3. Haptics (mobile)
| Event | Haptic |
|---|---|
| Guardian linked / profile saved | lightImpact |
| Unlink confirm | heavyImpact |
| Open row menu | selectionClick |
| Validation error / 4xx | mediumImpact |
| Rule | off when system disabled; none on web/desktop (08 §3) |
4. Motion language (module extension of 08 §4)
| Transition | Duration/curve | Used by |
|---|---|---|
| Route push list→detail | m-base slide | parents list, my children |
| Hero avatar list→detail | m-slow flight | parent/child cards |
| Link sheet | m-base bottom slide | link guardian |
| Unlink row removal | m-base fade-out | detail children list |
| Warning banner expand | AnimatedSize m-base | duplicate/primary warnings |
| Child switcher change | AnimatedSwitcher fade m-fast | my children |
| Skeleton→content | cross-fade m-slow | all loads |
Reduced-motion: fades + instant only; no stagger, no hero (08 §4).
5. Micro-interactions catalogue
| Interaction | Detail |
|---|---|
| Submit pressed | CTA loading swap m-base, double-tap locked |
| Switch toggle (pickup/primary) | m-fast thumb + icon morph; warning banner slides in if conflict |
| Duplicate link attempt | chip row shake (reduced-motion: none) + AppBanner(warning) |
| Unlink | dialog scale+fade m-base; on confirm heavyImpact + row fade + snackbar |
| Children count badge | scale pop m-fast on refresh change |
| Pull-to-refresh | primary spinner; lightImpact on completion |
6. Frustration controls (shared 08 §6 + module)
- All presses respond < 100 ms with
m-fastripple. - Every network action shows pending state (CTA spinner / skeleton).
- Submit disabled while pending — no double-submit (double link rows are a real risk, OQ-2).
- Rate-limit → countdown, no auto-retry.
- Errors persist until corrected/dismissed; never flash.
7. Special: picker & deep-link interactions
- User picker sheet: debounced
qsearch (300 ms,users.service.ts:90-115), Enter selects first result, Esc closes. - Student picker in link sheet: search by admission number or name; selection replaces field with chip; change reopens picker.
- Deep links:
studylyon://parents/:id→ detail (admin);studylyon://students/:id→ child if linked(forward-looking);studylyon://my/children→ switcher. - Clipboard: none in this module (no codes/keys).
8. Validation of interaction to server semantics
- "Switch primary" never patches a link — no link PATCH endpoint
(
student-parent-link.service.ts:16-41); the interaction performs delete+recreate and its copy must not imply an update. - "Unlink" maps exactly to
DELETE /parents/link/:linkIdsoft delete (student-parent-link.service.ts:38-41); 404 → treat-as-removed. - Primary toggles warn but are not blocked client-side — the server permits multi-primary (OQ-5); UI should never silently enforce what the server won't.
- Search field on the parents list is client-side filtering only (server ignores
q,parent.service.ts:58-61) — interaction spec forbids pretending it queries the server.
11 — Design System Mapping (Parents Module)
- 1. Theme recipes (module)
- 2. Widget → token
- 3. Typography mapping
- 4. Iconography
- 5. Motion tokens (values 00-shared/02 §6)
- 6. Contrast & dark parity
- 7. Breakpoint behaviour
- 8. Theming rules (enforced)
Parents module → Material 3 / Flutter / token mapping, extending 00-shared/04. Every color, type, spacing, elevation, radius, icon, motion reference traces to 00-shared/02_Design_Tokens.md.
1. Theme recipes (module)
| Screens | Theme | Exception |
|---|---|---|
| Admin workspace (list/detail/forms) | standard shell | primary accent for FAB + primary-guardian badge |
| Link sheet / unlink dialog | modal (surfaceContainerHigh) | warning states warning/error containers |
Parent self-service (my children/profile) (forward-looking) | standard shell | switcher selected state primaryContainer |
| Flags & badges | icon+label always (09 §9) | never color-only |
2. Widget → token
| Component | M3 / Flutter | Tokens | Elev | Radius |
|---|---|---|---|---|
| Parents list rows | ListTile | surface, onSurface/onSurfaceVariant | e-0 | r-md |
| Parent header card | AppCard | surfaceContainerLow | e-1 | r-lg |
| LinkedChildCard | AppCard | surfaceContainerLow | e-1 | r-lg |
| Primary badge | custom Badge | primaryContainer/onPrimaryContainer | — | r-sm |
| Relationship chip | Chip (disabled) | surfaceVariant/onSurfaceVariant | — | r-sm |
| FAB "Add guardian" | FloatingActionButton.extended | primaryContainer, icon primary | e-3 | r-full |
| Form fields | TextFormField | fill surfaceVariant, border outline, focus primary | — | r-md |
| Switches (pickup/primary) | SwitchListTile | primary selected | — | — |
| Link sheet | AppBottomSheet | surfaceContainerHigh | e-5 | r-xl top |
| Unlink dialog | AppDialog | surfaceContainerHigh; destructive CTA error | e-4 | r-lg |
| Warning banners | AppBanner | tertiaryContainer (duplicate/primary) | e-1 | — |
| Snackbar | SnackBar | inverseSurface | e-5 | r-md |
| Offline banner | AppOfflineBanner | tertiaryContainer | e-1 | — |
| Child switcher chips | FilterChip-style | selected primaryContainer, else surfaceVariant | — | r-full |
3. Typography mapping
| Token | Screen element |
|---|---|
headlineMedium | screen titles (Guardians, My children) |
titleLarge | header card names, dialog titles |
titleMedium | list titles, section headers, child names |
bodyMedium | labels, meta lines |
bodySmall | flags line, timestamps, hint "end of list" |
labelLarge | buttons, tabs |
labelMedium | badges, chips |
mono | admission numbers, IDs |
Numerals (priority, income, counts) use FontFeature.tabularFigures() (02 §2).
4. Iconography
Material Symbols outlined; module map: groups (empty list), person_add (FAB),
family_restroom, elderly, female, male, verified_user, home_work
(relationship icons), star (primary badge), directions_walk (pickup), payments
(financial), priority_high (emergency), link/link_off (link/unlink),
swap_horiz (switch primary), search (pickers), edit (edit).
5. Motion tokens (values 00-shared/02 §6)
| Purpose | Token | Applied to |
|---|---|---|
| Route transitions | m-base | list↔detail, forms |
| Hero avatar | m-slow | list→detail |
| Sheet/dialog | m-base | link sheet, unlink dialog |
| Row removal (unlink) | m-base fade | children list |
| Warning expand | m-base AnimatedSize | duplicate/primary banners |
| Switcher fade | m-fast | child switch |
| Badge pop | m-fast | children-count change |
| Skeleton→content | m-slow cross-fade | all loads |
Rules: reduced-motion substitutes fades/instants (08 §4).
6. Contrast & dark parity
- All text ≥ 4.5:1; interactive chrome ≥ 3:1; focus ring
primary3:1 vs adjacent. - Dark: chromatic roles TBD by designer (00-shared/02 §1.4).
- Flags (pickup/financial/priority) always icon + label — colorblind-safe (09 §9).
- Warning banners readable in both modes (
tertiaryContainer/onTertiaryContainer).
7. Breakpoint behaviour
| Class | Admin workspace | Parent self-service |
|---|---|---|
| phone <600 | single column; sheets full-height on keyboard | switcher + single child |
| 600–839 | list + detail panes (2-column) | switcher + detail |
| ≥1200 | master-detail 320–360 dp list; hover + keyboard shortcuts | centered child dashboard ≤ 720 dp |
8. Theming rules (enforced)
- No literal colors —
context.colorScheme.*only (00-shared/04 §7). - No literal spacing/durations.
- Tenant branding override affects
primaryonly (badge/CTA adapt automatically).
12 — API Mapping (Parents Module)
- 0. Module-wide request envelope & client policy
- Screen: Parents list
- Screen: Parent detail
- Screen: Create parent
- Screen: Edit parent
- Screen: Delete parent (soft)
- Screen: Link guardian (sheet)
- Screen: Guardians of a student (student-detail embed)
- Screen: Unlink (dialog)
- Loading / streaming / realtime
- Client-side error mapping table (module)
- Pagination
- Optimistic / undo
Exact wire contract for every screen → endpoint. Base
/api/v1; envelope per 00-shared/07. All endpoints fromsrc/modules/parents/controllers/parent.controller.ts; business rules fromparent.service.tsandstudent-parent-link.service.ts. Global guards:RateLimitGuard→JwtAuthGuard→RbacGuard(app.module.ts:129-131); no endpoint carries@Permissionsmetadata — parent endpoints are effectively JWT-only (OQ-8).
0. Module-wide request envelope & client policy
| Aspect | Contract |
|---|---|
| Base | https://api.<domain>/api/v1 |
| Headers | Authorization: Bearer <accessToken>; x-request-id client-generated; Content-Type: application/json |
| success | {success:true, message:"OK", data, meta?, timestamp, requestId} (response-envelope.interceptor.ts:47-60) |
| error | {success:false, message, error:{code, details?}, timestamp, requestId} (http-exception.filter.ts:73-81) |
| Codes | 400 VALIDATION_ERROR / 401 UNAUTHENTICATED / 403 PERMISSION_DENIED / 404 RESOURCE_NOT_FOUND / 409 DUPLICATE_RESOURCE / 422 BUSINESS_RULE_VIOLATION / 429 RATE_LIMITED / 5xx INTERNAL_SERVER_ERROR (http-exception.filter.ts:27-35) |
| Tenancy | tenantId from JWT only (jwt-auth.guard.ts:44-55); injected by repository (base.repository.ts:33-35) — never in body |
| Rate tier | api 100/min (global RateLimitGuard, prod-only) |
| Caching | none server-side for parents; client last-good cache (list) |
| Offline | reads cached; writes blocked |
| Retry | backoff on 5xx/network; no auto-retry on 429 |
Screen: Parents list
| Endpoint | GET /parents |
| Query | page (1-based), limit (1–100, default 20), sort, q — only page/limit honored (parent.service.ts:58-61; pagination-query.dto.ts:5-30) |
| Success | 200 data: ParentDocument[], meta:{page,limit,totalItems,totalPages,hasNext,hasPrevious} |
| Parent doc shape | {_id, tenantId, userId, occupation?, company?, annualIncome?, relationshipNotes?, emergencyContactPriority, pickupAuthorization, metadata?, createdAt, updatedAt, createdBy?, updatedBy?, version} (parent.schema.ts:8-32, base.schema.ts:8-35) |
| Client flow | page 1 → infinite scroll while meta.hasNext; RefreshIndicator reset |
| Errors | 400 bad page/limit; 401; 429; 5xx |
Screen: Parent detail
| Endpoint | GET /parents/:id → 200 data: ParentDocument; 404 RESOURCE_NOT_FOUND "Parent not found." (parent.service.ts:49-53) |
| Endpoint | GET /parents/:id/students → 200 data: StudentParentLinkDocument[] (raw link docs, parent.service.ts:69-72) |
| Link doc shape | {_id, tenantId, studentId, parentId, relationship, isPrimaryGuardian, financialResponsibility, pickupAllowed, emergencyPriority, metadata?, createdAt, updatedAt, version} (student-parent-link.schema.ts:16-41) |
| Join | client fetches GET /students/:id per studentId (OQ-10) |
| Errors | 404 (parent); 400 invalid id (CastError → "Invalid resource identifier.", http-exception.filter.ts:48,92) |
Screen: Create parent
| Endpoint | POST /parents body CreateParentDto (create-parent.dto.ts) |
| Required | userId (@IsMongoId) |
| Optional | occupation, company, annualIncome, relationshipNotes, emergencyContactPriority, pickupAuthorization |
| Success | 200/201 data: ParentDocument (Nest default 201 for @Post; envelope interceptor doesn't alter status — verify in e2e) |
| Side effect | ParentCreated event → in-app queue job parent-created (parent.service.ts:38-45, event-queue-map.ts:37) |
| Errors | 400 validation; 409 DUPLICATE_RESOURCE "Parent profile already exists for this user." (parent.service.ts:32-34); 429; 5xx |
Screen: Edit parent
| Endpoint | PATCH /parents/:id body UpdateParentDto (update-parent.dto.ts) — partial $set; userId not accepted |
| Success | 200 data: ParentDocument (updated; version incremented, base.repository.ts:62-65) |
| Side effect | ParentUpdated → audit-write log-parent-updated (parent.service.ts:78-86, event-queue-map.ts:38) |
| Errors | 400; 404 "Parent not found." (parent.service.ts:77); 429; 5xx |
Screen: Delete parent (soft)
| Endpoint | DELETE /parents/:id |
| Success | 200 data: undefined (void handler, parent.service.ts:89-100) |
| Side effect | ParentDeleted → audit-write log-parent-deleted (event-queue-map.ts:39); links are NOT touched (OQ-3) |
| Errors | 404 "Parent not found." (parent.service.ts:91) |
Screen: Link guardian (sheet)
| Endpoint | POST /parents/link/:studentId body LinkParentDto (link-parent.dto.ts) — studentId in the URL, not body |
| Required | parentId (@IsMongoId), relationship (string — enum not validated in DTO, OQ-6) |
| Optional | isPrimaryGuardian, financialResponsibility, pickupAllowed (default true), emergencyPriority |
| Success | 200/201 data: StudentParentLinkDocument |
| Validation reality | studentRepo.findById(studentId) result discarded — missing student does not 404 (OQ-3); duplicates allowed (OQ-2) |
| Errors | 400 (bad ids/fields); 429; 5xx (invalid relationship → Mongoose ValidationError → 500, OQ-6) |
Screen: Guardians of a student (student-detail embed)
| Endpoint | GET /parents/link/student/:studentId → 200 data: StudentParentLinkDocument[] (student-parent-link.service.ts:34-36) |
| Join | client fetches GET /parents/:id per parentId |
Screen: Unlink (dialog)
| Endpoint | DELETE /parents/link/:linkId |
| Success | 200 data: undefined (void, student-parent-link.service.ts:38-41) — soft delete |
| Errors | 404 RESOURCE_NOT_FOUND "Link not found." (student-parent-link.service.ts:40) |
Loading / streaming / realtime
| Screen | Loading | Streaming | Realtime |
|---|---|---|---|
| parents list | AppSkeleton(list) | — | — |
| parent detail | header skeleton + children skeletons | children load after header | — |
| create/edit/link | CTA spinner | — | — |
my children (forward-looking) | skeleton | — | (planned) WS parent.linked |
Client-side error mapping table (module)
| Screen | code | UI |
|---|---|---|
| create | 409 | banner + "Open existing profile" |
| any form | 400 | per-field details |
| detail/edit | 404 | AppErrorState + back |
| unlink | 404 | treat-as-removed |
| link | 500 (invalid relationship) | generic + requestId (client prevents via dropdown) |
| any | 401 | silent refresh → session expiry |
| any | 429 | countdown |
| any | 5xx | generic + requestId + retry |
Pagination
GET /parents is the only paginated endpoint (meta as above). Link lists
(GET /parents/:id/students, GET /parents/link/student/:studentId) are
non-paginated full arrays (student-parent-link.repository.ts:20-26) — client
renders all; per-student join is bounded by household size.
Optimistic / undo
- No optimistic writes on any parents mutation — link/unlink are consequential; server-confirm everywhere (00-shared/06 §3.5).
- Undo: none (no server endpoint reverses an unlink; soft-deleted links are not restorable via API).
- Read lists:
RefreshIndicatoralways bypasses client cache.
13 — State Management (Parents Module)
- 1. Module-level: ParentsStore (shared link cache, admin workspace)
- 2. Per-screen Cubits
- 3. State objects (concise)
- 4. Events & actions map (UI → Cubit → API)
- 5. Caching & refresh
- 6. Realtime
- 7. Error states per action
- 8. Testing hooks (
00-shared/06 §6) - 9. Cross-cutting interplay
Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) + the module-wide guardian-link cache that admin screens share. Backed by
ParentsRepository(dio) calling the endpoints in 12_API_Mapping.md.
1. Module-level: ParentsStore (shared link cache, admin workspace)
- One
ParentsStore(Cubit-injected singleton within the module scope) holds:List<ParentRef> listPage,meta, and a per-parentMap<parentId, List<Link>> links. - Invariant: any mutation (link create/delete) invalidates the affected parent's links cache so every screen re-fetches the children section on next open.
- No persistence for links (server truth; small data); list uses last-good cache per
00-shared/06 §3.3 (TTL 5 min;
RefreshIndicatorbypasses).
2. Per-screen Cubits
| Screen | Cubit | Events → State |
|---|---|---|
| Parents list | ParentsCubit | Load, Refresh, LoadMore, Retry → {initial, loading, loaded(items, meta), empty, error(code), loadingMore} |
| Parent detail | ParentDetailCubit | Load(id), Refresh, Retry → {initial, loading, loaded(parent, links, students), notFound, error(code)} |
| Create/Edit form | ParentFormCubit | Init(existing?), SetField, Submit(form) → {idle, dirty, saving, saved(parentId), duplicate(existingId), validation(errors), error(code)} |
| Link sheet | LinkCubit | Init(context) (parentId+studentId known), PickStudent, SetRelationship, TogglePrimary/Pickup/Financial, Submit → {idle, searching, ready, saving, linked(link), warnDuplicate, warnSecondPrimary, error} |
| Unlink | UnlinkCubit | Confirm(linkId) → {idle, confirming, done, notFound, error} |
My children (forward-looking) | ParentChildrenCubit | Load() → {initial, loading, loaded(children), empty, error} |
| Child switcher | ChildSwitcherCubit | SwitchChild(id) → {children, selectedChildId} |
My profile (forward-looking) | MyProfileCubit | Load(), Submit(form) → {initial, loading, loaded(profile), saving, saved, error} |
3. State objects (concise)
class ParentRef {
final String id; String? userId;
String? occupation, company, relationshipNotes;
num? annualIncome; int emergencyContactPriority;
bool pickupAuthorization;
DateTime createdAt, updatedAt;
}
class Link {
final String id; String studentId, parentId;
RelationshipType relationship; // mother|father|guardian|grandparent|relative|foster_parent
bool isPrimaryGuardian, financialResponsibility, pickupAllowed;
int emergencyPriority;
}
class StudentRef { final String id; String admissionNumber; String? rollNumber; String? gradeId, sectionId, classId; String status; }
4. Events & actions map (UI → Cubit → API)
| UI event | Cubit method | Repository call |
|---|---|---|
| list open / pull | load() / refresh() | repo.parents(page, limit) |
| scroll end | loadMore() | repo.parents(page+1) |
| row open | detail.load(id) | repo.parent(id) + repo.parentLinks(id) + per-child repo.student(id) |
| create submit | form.submit() | repo.createParent(dto) |
| edit submit | form.submit() | repo.updateParent(id, dto) |
| delete | detail.remove(id) (menu) | repo.deleteParent(id) |
| link submit | link.submit() | repo.linkParent(studentId, linkDto) |
| student guardians embed | studentsGuardians.load(studentId) | repo.studentLinks(studentId) + per-parent repo.parent(id) |
| unlink confirm | unlink.confirm(linkId) | repo.unlink(linkId) |
| set primary | link.switchPrimary(link) | repo.unlink(oldPrimary) + repo.linkParent(studentId, newDto) (OQ-4) |
| child switch | switcher.switch(id) | — (local state) |
5. Caching & refresh
- Parents list: last-good cache
sl:cache:parents:{tenant}:{page};RefreshIndicatorbypasses; infinite-scroll appends. - Detail: no cache — always fetch on open; children section re-fetches after any link mutation (invariant §1).
- My children
(forward-looking): last-good cache 5 min + banner; child switcher selection persisted in memory only (per session).
6. Realtime
- No WS surface today.
ParentCreated→in-appnotification job (event-queue-map.ts:37); when the notification center + WS ship(planned), aparent.linkedpush invalidates the detail cache and nudges refresh (00-shared/06 §3.4).
7. Error states per action
| Action | Error | State → |
|---|---|---|
| create | 409 | duplicate(existingId) → banner + open existing |
| any | 404 | notFound → AppErrorState (detail) or treat-as-removed (unlink) |
| link | duplicate pre-check | warnDuplicate → warning + block submit (OQ-2) |
| link | second primary | warnSecondPrimary → warning, submit allowed (OQ-5) |
| any | 429 | rateLimited → countdown |
| any | 401 | session expiry flow (global) |
| any | 5xx | error(code) → AppErrorState + requestId |
8. Testing hooks (00-shared/06 §6)
- Pure-Dart cubits; unit-test: pagination mixin (append/refresh/meta), primary-switch two-step sequencing, duplicate-link warning logic, 404 mapping.
- Widget tests: list 3 states; detail children skeleton→loaded/empty; form duplicate/validation; link sheet warnings; switcher selection.
9. Cross-cutting interplay
ConnectivityCubitgates all writes offline; reads serve cache + banner.AuthCubitsession expiry → re-login; module state discarded (no cross-login persistence).- Role changes (RBAC
(planned)) rebuild route visibility;ParentsCubitsurvives only underparent.read. TenantContext(fromAuthCubit) implicit in every repository call — never stored client-side per record.
14 — QA Checklist (Parents Module)
- 1. Functional
- 2. Visual / layout
- 3. Accessibility (00-shared/09)
- 4. Performance
- 5. Offline & network
- 6. Tablet / desktop
- 7. Localization
- 8. Permissions
- 9. Dark mode
- 10. Animations
- 11. Security
- 12. Server-backed edge mirrors
- QA scripts
Module QA specific to framework + shared 00-shared/10. Every check derives from a specific backend behavior so failure analysis is direct.
1. Functional
Profile CRUD
-
POST /parentswith validuserId→ 200/201dataparent doc (parent.service.ts:29-47). -
Same
userIdagain → 409DUPLICATE_RESOURCE"Parent profile already exists for this user." (parent.service.ts:32-34). -
GET /parentspaginated:meta= page/limit/totalItems/totalPages/hasNext/hasPrevious;limit> 100 → 400 (pagination-query.dto.ts:17-19). -
GET /parents?q=&sort=— confirm ignored server-side (expected today,parent.service.ts:58-61) — client filter must not pretend to search (OQ-7). -
GET /parents/:id→ 404 "Parent not found." for unknown id (parent.service.ts:51). -
PATCH /parents/:idpartial update;userIdin body → silently ignored (no field inUpdateParentDto). -
DELETE /parents/:idsoft-delete: subsequent list/detail exclude it;isDeletedset (base.repository.ts:68-74). -
Invalid ObjectId path param → 400
VALIDATION_ERROR"Invalid resource identifier." (http-exception.filter.ts:48,92).
Link validation
-
POST /parents/link/:studentIdcreates link withrelationship, flags,emergencyPriority(student-parent-link.service.ts:16-32). -
Known gap: link to a non-existent student succeeds (findById result discarded,
student-parent-link.service.ts:27) — QA must record behavior; decide with product (OQ-3). -
Known gap: duplicate
(studentId, parentId)link succeeds (non-unique index,student-parent-link.schema.ts:46) — QA must decide client-block or server-fix (OQ-2). -
Known gap: invalid
relationshipstring → 500 (schema enum, no DTO enum,link-parent.dto.ts:19-20) — client must only send enum values (OQ-6). -
GET /parents/link/student/:studentIdreturns all links for student; empty array when none (student-parent-link.repository.ts:20-22). -
DELETE /parents/link/:linkId→ 200; repeat → 404 "Link not found." (student-parent-link.service.ts:40).
Primary-guardian switches
-
Primary flag stored per link (
isPrimaryGuardian,student-parent-link.schema.ts:27-29). - Known gap: two links for one student can both be primary (no exclusivity, OQ-5) — QA verifies client warning + documents server behavior.
-
Switch-primary flow (unlink old + recreate new, OQ-4) preserves all other flags and ordering; verify no partial state if second call fails (rollback handling in
LinkCubit). - Unlink of primary guardian leaves student with zero primaries; warning shown (OQ-4).
Unlink with active links
-
Unlink does not delete the parent profile (
parent.service.ts:89-100vsstudent-parent-link.service.ts:38-41). - Parent with zero links still lists/opens fine (findLinks returns []).
- Delete parent with active links: links remain, orphaned — children section must handle student-missing/parent-missing join gracefully (OQ-3).
Privacy: parent sees only own children
-
Cross-tenant: tenant A token listing parents → only tenant A docs (
base.repository.ts:20-30). -
Parent role: no my-children endpoint exists — UI must not fake it (OQ-1); when
(planned)lands: verify parent sees exactly linked children, other children 404/absent. -
Platform admin bypasses tenant scope (
base.repository.ts:21-23) — verify intended. -
No parent.* permission exists (
permissions.constants.ts) — any JWT can create/delete parents today; QA flags this as known risk until RBAC (OQ-8).
Multi-child households
-
One parent linked to 3 students → 3 link rows; detail groups per child;
GET /parents/:id/studentsreturns all (parent.service.ts:69-72). - Two parents (mother+father) linked to same student → 2 rows with different relationships; per-link flags independent.
-
Student join: child cards render from
GET /students/:idper link (OQ-10); archived/gone student handled.
2. Visual / layout
- List/detail/forms dark-light parity; no hardcoded colors (00-shared/10 §6).
- Long names + 3 children badges wrap at 200% text scale; EmergencyFlagsRow wraps.
- Master-detail ≥ 840 dp; phone single pane; sheets full-height on keyboard.
3. Accessibility (00-shared/09)
- TalkBack: list → detail → link sheet → unlink full journey without sight.
- VoiceOver separately.
-
Keyboard-only desktop: Tab order, Enter submit, Esc closes sheet/dialog,
Nnew. -
Relationship chips + primary badge announced; switcher
toggledsemantics. - Flags never color-only (icon+label).
- Contrast 4.5:1 text / 3:1 chrome; reduced-motion off (no shake/stagger).
4. Performance
- List skeleton ≤ 200 ms; infinite scroll appends without duplicate parallel page requests (00-shared/10 §1).
- Detail children join: N+1 bounded; no repeated fetches on rebuild (cache per parent).
-
No duplicate parallel requests for same student while joining (dedupe map in
ParentDetailCubit).
5. Offline & network
- Offline list → last-good cache + banner; writes blocked.
- Link submit offline → blocked with guidance (no silent failure).
- Network loss mid-submit → no duplicate rows on retry known risk (no idempotency, OQ-2) — QA: verify one retry creates one row only if first failed.
6. Tablet / desktop
-
Master-detail list+detail; hover states; right-click menus;
Esc; breadcrumbs depth > 2 (00-shared/05 §3).
7. Localization
-
All strings i18n keys (
parents.*); relationship labels localized; RTL-safe chips row. -
Currency/income format via
Intl; dates locale-aware.
8. Permissions
-
Today: JWT-only (documented risk, OQ-8). When RBAC lands:
parent.read/create/update/delete/linkgate each surface (04-Modules/Parents.md:64-70); self-service for parent role only (role.schema.ts:50-56). -
Cross-tenant link creation impossible (tenantId injected,
base.repository.ts:33-35).
9. Dark mode
-
Full visual pass; primary badge
primaryContainerdark variant (TBD by designer, 00-shared/02 §1.4); banners readable.
10. Animations
- Tokens only; reduced-motion; no flashes on unlink row removal.
11. Security
- No PII logged client-side; tokens secure storage (00-shared/10 §3).
-
404 vs 403 semantics: unknown parent → 404 (no existence leak across tenants — repository scoping returns empty → 404,
parent.service.ts:49-53). -
5xx internals never surfaced (filter,
http-exception.filter.ts:60-65).
12. Server-backed edge mirrors
-
emergencyPriority/annualIncomeaccept numbers only — client coerces; note DTO has no type decorators (create-parent.dto.ts:18-34). -
pickupAllowedlink default true vspickupAuthorizationprofile default false — QA cross-checks display on both surfaces. -
Event side effects:
ParentCreated→ in-app job;ParentUpdated/ParentDeleted→ audit-write jobs (event-queue-map.ts:37-39); verify in-app notification appears(planned)UI.
QA scripts
- Golden per module component (light/dark × phone/tablet/desktop × states) (07 §10).
- E2E (P0): create user → create parent → link to student → set primary → unlink → delete parent (device-cloud).
- Regression: duplicate-link, double-primary, orphan-link join cases recorded as known gaps with product decisions (OQ-2..OQ-6).
15 — Flutter Implementation Guide (Parents Module)
- 1. Folder structure
- 2. Dependencies
- 3. Cubits
- 4. ParentsRepository (single)
- 5. Navigation (go_router)
- 6. Theme
- 7. Extensions
- 8. Localization keys
- 9. Storage & security
- 10. Testing
- 11. Performance
- 12. Proposals flagged to the team
How to build the Parents feature in the Flutter client on top of 00-shared/11. Forward-looking spec; no client repo exists yet.
1. Folder structure
features/parents/
├── domain/
│ ├── models/
│ │ ├── parent.dart # ParentRef (schema mirror, parent.schema.ts:8-32)
│ │ ├── link.dart # Link + RelationshipType enum (student-parent-link.schema.ts:7-41)
│ │ ├── student_ref.dart # join view (student.schema.ts:16-64)
│ │ └── parent_user.dart # displayName/photo from users join
│ └── exceptions/parents_exceptions.dart
├── data/
│ ├── dto/
│ │ ├── create_parent_dto.dart
│ │ ├── update_parent_dto.dart
│ │ └── link_parent_dto.dart
│ └── repositories/
│ └── parents_repository.dart
└── presentation/
├── cubit/
│ ├── parents_cubit.dart
│ ├── parent_detail_cubit.dart
│ ├── parent_form_cubit.dart
│ ├── link_cubit.dart
│ ├── unlink_cubit.dart
│ ├── parent_children_cubit.dart # (forward-looking)
│ ├── child_switcher_cubit.dart # (forward-looking)
│ └── my_profile_cubit.dart # (forward-looking)
├── pages/
│ ├── parents_page.dart
│ ├── parent_detail_page.dart
│ ├── parent_form_page.dart
│ ├── link_sheet.dart
│ ├── my_children_page.dart # (forward-looking)
│ └── my_profile_page.dart # (forward-looking)
└── widgets/
├── parent_list_tile.dart
├── linked_child_card.dart
├── relationship_chip.dart
├── primary_guardian_badge.dart
├── emergency_flags_row.dart
├── user_picker_field.dart
└── child_switcher_bar.dart
2. Dependencies
Base set from 00-shared/11 §1 (flutter_bloc, dio, go_router, get_it, intl,
cached_network_image, connectivity_plus, secure_storage). No module-specific
package needed (dropdowns/switches are material).
3. Cubits
ParentsCubitusesPaginatedListMixin<ParentRef>(00-shared/06 §3.2);loadMore()guarded byhasNext;qnever sent (server ignores — OQ-7).ParentDetailCubitorchestrates the join:parent(id)+parentLinks(id)+ dedupedstudent(id)per link (00-shared/11 §4DTO→model mapping).LinkCubitowns the two pre-check warnings (duplicate pair, second primary) over the loaded links set; submit disabled onwarnDuplicate.ChildSwitcherCubit:selectedChildIdonly; read by other modules via injected instance (in-memory, session-scoped).
4. ParentsRepository (single)
class ParentsRepository {
// throws ApiException(code,status) via AppDio error interceptor
Future<Paginated<ParentRef>> parents({int page = 1, int limit = 20});
Future<ParentRef> parent(String id);
Future<List<Link>> parentLinks(String parentId); // GET /parents/:id/students
Future<List<Link>> studentLinks(String studentId); // GET /parents/link/student/:studentId
Future<ParentRef> createParent(CreateParentDto dto);
Future<ParentRef> updateParent(String id, UpdateParentDto dto);
Future<void> deleteParent(String id); // soft delete
Future<Link> linkParent(String studentId, LinkParentDto dto); // POST /parents/link/:studentId
Future<void> unlink(String linkId); // DELETE /parents/link/:linkId
Future<StudentRef> student(String id); // join helper → students repo
}
- Identity join: parent display name/avatar come from
users—ParentsRepositoryneeds auser(id)/users-search helper (users.service.ts:90-115), orParentRefcarriesuserIdand the UI joins lazily.
5. Navigation (go_router)
GoRoute /parents → ParentsPage (guard: parent.read planned)
├─ /parents/new → ParentFormPage(create)
├─ /parents/:id → ParentDetailPage
│ └─ /parents/:id/edit → ParentFormPage(edit)
└─ (sheet) /parents/link → LinkSheet (modal)
/my/children → MyChildrenPage (parent role, forward-looking)
/my/profile → MyProfilePage (forward-looking)
- Route guards mirror
00-shared/05 §9; until RBAC lands, guard on role client-side (parent.readabsent today — OQ-8) and hide admin workspace forparentrole. - Deep links
studylyon://parents/:id,studylyon://students/:id(linked-only check).
6. Theme
AppTheme.light()/dark() unchanged (00-shared/04 §1); module adds no tokens; primary
badge uses colorScheme.primaryContainer.
7. Extensions
RelationshipType.label(context)→ i18n + icon map.Link.isPrimaryshorthand;StudentRef.gradeLabel(context).int.tabular()for priority/income numerals (FontFeature.tabularFigures,02 §2).
8. Localization keys
parents.list.*, parents.detail.*, parents.form.*, parents.link.*,
parents.unlink.*, parents.relationship.*, parents.my.* (en .arb first;
relationship labels for mother/father/guardian/grandparent/relative/foster_parent
localized per org demand, 00-shared/11 §9).
9. Storage & security
- No sensitive data in this module; list last-good cache in
shared_preferences/Hive key{tenant}:parents:{page}(00-shared/11 §11); never cache link data for theparentrole (privacy boundary).
10. Testing
- Unit: cubits with mocked repo — pagination append/refresh; duplicate-link pre-check; primary-switch two-step (unlink→recreate) failure rollback; 404 mapping.
- Widget: list 3 states; detail children skeleton/empty/error; form 409 banner; link sheet warnings; switcher selection.
- Golden: components + pages light/dark × 3 sizes (
00-shared/10 §9). - Integration: create user → create parent → link → unlink → delete (mock server); offline list banner.
- E2E (P0): admin links guardian to student; verify row + primary badge; unlink confirm; delete parent; parent-login my-children (once endpoint lands).
11. Performance
ListView.builderfor parents list + children section; const constructors; avatar imagescached_network_imagewith resize; dedupe map for student joins; no rebuild of full detail on child-card state change (RepaintBoundary).
12. Proposals flagged to the team
- Backend: add
GET /parents/me(oruserIdfilter) + link-populated children endpoint — unblocks the entire parent self-service surface (OQ-1). - Backend: unique index on
(tenantId, studentId, parentId)+ 409 on duplicate link; enforcefindByIdresult inlinkStudentParent(OQ-2/OQ-3). - Backend: link PATCH endpoint (or switch-primary endpoint) to replace the delete+recreate workaround (OQ-4); consider single-primary rule (OQ-5).
- Backend:
@IsEnum(RelationshipType)inLinkParentDto(OQ-6). - Backend/RBAC: seed
parent.*permissions (04-Modules/Parents.md:64-70) and decorate endpoints (OQ-8). - Analytics events (
parents.*) waitAnalyticsService(00-shared/10 §8).
01 — Product Overview (Payments)
- 1. Purpose
- 2. Business goals
- 3. User goals
- 4. Stakeholders
- 5. Why this exists
- 6. Dependencies
- 7. Success metrics
- 8. Edge cases
- 9. Assumptions
- 10. Glossary
StudyLyon payments: processing, refunds, reconciliation, receipts, invoice-credit sync.
1. Purpose
Capture every payment received from parents/guardians (and any payer) against fee
invoices, issue receipts, track refunds, and keep invoice dues (paidAmount, status)
in sync. Per PRD §8, payment-gateway settlement is out of scope (Phase 1) →
payments are recorded either from a gateway response or manually, and reconciled by the
accountant.
2. Business goals
- Record a payment with full audit context (payer, gateway, amount, invoice).
- Generate a human-readable receipt for every completed payment.
- Track full/partial refunds against a payment.
- Keep invoice
paidAmount/status(PAID / PARTIAL) accurate. - Support reconciliation of asynchronous gateway outcomes.
3. User goals
- Accountant: record a cash/cheque/transfer payment fast; see all payments and receipts; refund when required.
- Receptionist: take a counter payment against an invoice and hand over a receipt.
- Org Admin: monitor collection and reconcile gateway responses.
- Parent (forward-looking): see payment status and download receipts (self-service endpoint does not exist yet).
4. Stakeholders
Accountants, receptionists, org admins, platform billing (via payments.reconcile),
parents (read), external gateways (stripe, paypal, flutterwave, paystack, razorpay,
cash, bank_transfer, cheque per PaymentGateway).
5. Why this exists
Manual ledgers and split tools make fee collection untraceable. This module centralizes
payments, receipts, refunds and reconciliation, emitting PaymentProcessed /
PaymentRefunded domain events for realtime + audit.
6. Dependencies
Invoices(fees module) — link and partial/paid statusEventBus(events module) →PaymentProcessed/PaymentRefundedevents to WS tenant room; note: these events are NOT routed to any BullMQ queue inevent-queue-map.tsStorage/files(not used today; receipts are data rows, not PDFs)- RBAC perms exist (
payments.read/process/refund/reconcile,receipts.read) but are not yet enforced server-side (controller isJwtAuthGuardonly)
7. Success metrics
- Payment → receipt latency < 1 s (synchronous, same request)
- Invoice status converges to PAID/PARTIAL on every payment/refund
- Zero orphan receipts (receipt always linked to a payment)
- Refund over-payment impossible (blocked server-side)
8. Edge cases
- Refund attempt on non-
COMPLETEDpayment → 409 - Refund amount exceeding payment → 409
- Partial refund then further refund →
PARTIALLY_REFUNDEDthenREFUNDED - Revenue link: payment with no
invoiceId(off-invoice) → no invoice link - Gateway async failure →
PATCH reconcilesets FAILED/PENDING (client must re-fetch) - Duplicate
transactionReference→ unique index violation (500 today; retry-safety gap) - Receipt number generated from an in-process counter — not concurrency-safe across multiple API replicas
- Concurrent payments for the same invoice: no lock;
sumByInvoiceis aggregation at link time (order-dependent final status) - Dual payment models (see 12_Assumptions)
9. Assumptions
- Money stored as floating-point
Number(amount,fee,refundedAmount) — financial apps should prefer minor units; flagged as an improvement, not corrected here. - Mobile client is a forward-looking spec (PRD excludes native apps from Phase 1).
- Receipts are database rows; PDF/printable receipt via reports module
(planned). - Parent self-service payment flows are
(forward-looking)— no/payments/meendpoint. - Reconciliation is manual (
PATCH :transactionRef/reconcilewith{status}body).
10. Glossary
| Term | Meaning |
|---|---|
| transactionReference | Unique TXN-{timestamp}-{uuid8} |
| gateway | PaymentGateway enum incl. offline methods (cash/cheque/bank_transfer) |
| receiptNumber | RCP-{timestamp}-{counter} |
| reconcile | Set payment status from gateway outcome string ("success"→COMPLETED, "failed"→FAILED, else PENDING) |
| InvoiceStatus | PAID / PARTIAL / (per fees, incl. PENDING) |
02 — User Personas (Payments)
Roles that touch payments, with exact permissions from
permissions.constants.ts.
Accountant (primary)
- Goals: record payments quickly, issue receipts, refund accurately, reconcile gateway outcomes, keep invoices PAID/PARTIAL correct.
- Pain points: manual ledger duplication; verifying gateway outcomes; refund mistakes; money displayed as floats.
- Frequency: daily (collection peaks on due dates).
- Permissions (defined, not yet enforced):
payments.read,payments.process,payments.refund,payments.reconcile,receipts.read,fees.collect. - Needs: quick payment form (amount prefilled from invoice), receipt preview, refund guard rails (reason required by process), status history.
- Restrictions: no academic write access.
- Mental model: "payment completes an invoice; refund reverses it; reconcile fixes a pending state."
- Expected behaviour: every completed payment instantly produces a receipt; invoice due amount updates immediately.
Receptionist / Bursar
- Goals: take counter payments (cash/cheque/bank transfer) and hand the payer a receipt.
- Pain points: slow forms when parents queue; wrong-payer mistakes.
- Frequency: several per day.
- Permissions: as delegated by org (client-gated; server enforcement
(planned)). - Needs: payer search/autofill, prefilled amount from invoice, print/share receipt.
- Restrictions: should not refund without approval (client-gated).
- Mental model: "cash register."
- Expected behaviour: under 60 s per payment.
Org Admin
- Goals: monitor collections, drill into a payment, resolve disputes, reconcile.
- Pain points: duplicated payment records across modules (see dual-schema issue).
- Frequency: weekly review.
- Permissions: same payments.* set.
- Expected behaviour: payment list filterable; every payment traceable to invoice + receipt.
Parent (forward-looking)
- Goals: see payment status, get receipts, pay online.
- Pain points: no self-service today.
- Restrictions: only own linked children's invoices (privacy boundary
(planned)). - Expected behaviour: push notification on
PaymentProcessedfor their invoice.
System actors
- Gateway (async): calls back / accountant reconciles via PATCH.
- Event bus:
PaymentProcessed/PaymentRefunded→ WS broadcast to tenant room; audit logging via global pipeline.
03 — User Journey (Payments)
- 1. Record a counter payment (Accountant / Receptionist)
- 2. Refund a payment (Accountant)
- 3. Reconcile a gateway outcome
- 4. Search an audit trail (Org Admin)
- Common journey attributes
End-to-end payment journeys. All flows assume Bearer JWT; every step uses the shared envelope contract (00-shared/07_API_Conventions.md).
1. Record a counter payment (Accountant / Receptionist)
sequenceDiagram
participant U as Accountant
participant A as App
participant API as /payments
U->>A: Open Payments > New payment
A->>API: POST /payments {amount, gateway:cash, invoiceId, payerName, payerEmail}
API-->>A: 201 {payment, receipt}
A-->>U: Success screen: payment detail + receipt card (print/share)
Note over A,U: invoice paidAmount/status updated server-side (PAID|PARTIAL)
- Entry: Fees → invoice detail → "Record payment"; or Payments FAB → New payment.
- Intent: credit an invoice instantly.
- Decision points: gateway (cash/cheque/bank_transfer), prefill amount from invoice, attach payer identity.
- System response: synchronous; skeleton → success view. Receipt number shown.
- Failures: 400 validation (amount Min 0), 409 duplicate reference, network → retry prompt. Never optimistic — payment must be confirmed by the server.
- Exit: "Done" → back to invoice (refreshed) or payments list.
- Abandonment: form keeps entered values on back; not submitted until confirm.
- Offline: blocked — must be online; guidance toast.
- Multi-device: invoice status diverges if another clerk is mid-payment; pull-to-refresh.
2. Refund a payment (Accountant)
sequenceDiagram
U->>A: Payments list > tap completed payment > Refund
A->>A: Confirm sheet (reason required, shows refundable amount)
A->>API: POST /payments/refund {paymentId, amount?}
alt full refund
API-->>A: status=refunded
else partial
API-->>A: status=partially_refunded
end
A-->>U: Success snackbar + updated payment
- **Decision: full vs partial (amount optional = full).
- Failures:
- 409 "Only completed payments can be refunded." (status banner)
- 409 "Refund amount exceeds payment amount." (inline amount error)
- 404 payment missing.
- Recovery: retry with corrected amount.
3. Reconcile a gateway outcome
flowchart LR
G[Gateway async event] --> P[Payment PENDING]
P --> R[Accountant opens reconcile screen]
R --> S[PATCH /payments/:ref/reconcile {status}]
S -->|success| C[completed]
S -->|failed| F[failed]
S -->|other / empty| PEND[pending]
- Entry: payments list row with "pending/processing" badge → Reconcile; or gateway callback page.
- Exit: back to list with updated status.
- Permission denial: 403 → reconcile action hidden (client-gated per
payments.reconcile).
4. Search an audit trail (Org Admin)
- Payments list (paged
GET /payments?page&limit, sortedcreatedAt desc) → filter by invoice / status (client-side; no server filters) → detail → view receipt (GET /payments/receipts/:id). - Deep link (forward-looking):
studylyon://payments/:id.
Common journey attributes
| Concern | Behaviour |
|---|---|
| Loading | AppSkeleton lists; button spinner on submit |
| Session expiry | silent refresh; fail → re-login, payment preserved as draft |
| Timeout | 15 s; 429 → countdown + disable retry |
| Conflict resolution | refresh list; server state is source of truth |
| Network loss | offline banner; no optimistic writes |
| Push entry | (forward-looking) PaymentProcessed notification → deep link |
| Email entry | (planned) receipt email via PaymentCompleted→emails routing (dead in map today) |
| Abandonment | forms discarded with confirm prompt |
04 — Information Architecture (Payments)
- 1. Placement in app shell (00-shared/05)
- 2. Screen map
- 3. Navigation rules
- 4. Quick actions & context menus
- 5. Deep links (forward-looking)
- 6. Empty / edge surfaces
Screen hierarchy and navigation for the payments module.
1. Placement in app shell (00-shared/05)
- Phone: Fees tab contains "Payments" sub-destination; also reachable from Invoice detail action menu.
- Tablet/desktop: Fees → Payments as master-detail branch; receipt preview in right pane.
2. Screen map
Payments (list) ─ route /payments
├─ Payment detail ─ /payments/:id
│ ├─ Receipt view ─ /payments/receipts/:receiptId
│ ├─ Refund sheet (modal)
│ └─ Reconcile sheet (modal, PENDING/PROCESSING only)
├─ New payment ─ /payments/new (full-screen form)
│ └─ Success + receipt card (inline result)
└─ Receipts (list) ─ /payments/receipts
3. Navigation rules
- Lists push detail (phone) / right pane (tablet+).
- "Record payment" FAB on Payments list; contextual action on invoice detail.
- Modal hierarchy: snackbar → bottom sheet (refund/reconcile) → dialog (confirm) → full-screen (new payment form).
- Destructive/irreversible (refund) uses
AppDialogconfirm with reason field first. - Back: list → fees; sheet dismiss = cancel.
- Global search: payments searchable via global search
(planned); local filter client-side.
4. Quick actions & context menus
- Row overflow menu: View, Receipt, Refund (if COMPLETED), Reconcile (if PENDING/PROCESSING).
- FAB: New payment (hidden while scrolling down).
5. Deep links (forward-looking)
studylyon://payments/:id→ payment detailstudylyon://payments/receipts/:id→ receiptstudylyon://invoice/:invoiceId→ invoice detail (payments tab)
6. Empty / edge surfaces
- Payments list empty: icon + "No payments yet — record the first payment".
- Receipts empty similarly.
- List end-of-page indicator (paged, no infinite scroll).
05 — Screen Inventory (Payments)
- S1 — Payments list
- S2 — Payment detail
- S3 — New payment (form screen)
- S4 — Refund sheet (bottom sheet)
- S5 — Reconcile sheet
- S6 — Receipt view
- S7 — Receipts list
- S8 — Payment context on invoice detail (fees module)
Every screen in the payments module. States, permissions, a11y, analytics per screen.
Screen codes: S1 Payments list · S2 Payment detail · S3 New payment · S4 Refund sheet · S5 Reconcile sheet · S6 Receipt view · S7 Receipts list · S8 Payment on invoice (context).
S1 — Payments list
- Purpose: browse all tenant payments, newest first; drill into details; record new.
- Route:
/payments - Entry: Fees tab → Payments; deep link; after successful payment.
- Exit: payment detail, new payment, receipts list.
- Widgets:
AppSearchBar(client-side filter), statusFilterChips (client-side),AppCard/AppListTilerows (transactionReference, amount, payerName, gateway icon, statusAppBadge, date), FAB "Record payment", pagination footer. - Buttons: FAB (primary CTA), row menu (overflow).
- States: loading skeleton (8 rows) → empty → error (retry) → data; offline banner.
- Pagination:
page/limit(default 20) via server{data,total}; "Load more" button (no auto-scroll). - Permissions: visible with
payments.read(client-gated; server(planned)). - Keyboard: Enter submits filter;
/focuses search (desktop). - Responsive: single column phone; two-column grid tablet; table + filters desktop.
- A11y: rows announce "Payment TXN-… amount … status"; status never color-only (icon + label).
- Micro-interactions: row tap ripple; status chip pop on update.
- Analytics:
payments.list.view,payments.list.filter,payments.list.open_detail.
S2 — Payment detail
- Purpose: full payment record + linked invoice + receipt access + actions.
- Route:
/payments/:id - Entry: list tap, deep link, notification.
- Exit: receipt, refund/reconcile sheets, invoice detail.
- Sections: payment header (amount large, status badge, transaction reference), payer info card, invoice link card (status, paidAmount), gateway + timestamps card, refund history (refundedAmount vs amount), actions.
- Actions: Receipt (primary tonal), Refund (destructive, only COMPLETED), Reconcile (only PENDING/PROCESSING), Copy transaction reference.
- States: skeleton → data / 404 error (
RESOURCE_NOT_FOUNDempty state with copy "Payment not found"). - Permissions: view
payments.read; actions gatedpayments.refund/payments.reconcile(hide when absent). - A11y: money amounts
tabularFigures; actions ≥ 48 dp. - Analytics:
payments.detail.view,payments.detail.refund_start,payments.detail.reconcile.
S3 — New payment (form screen)
- Purpose: record a payment; always server-confirmed (never optimistic).
- Route:
/payments/new(or prefilled via invoice context) - Widgets: amount field (decimal, prefilled from invoice), currency (default USD, read-only until server supports multi-currency), gateway dropdown (enum incl. cash/cheque/bank_transfer), gatewayTransactionId (optional, gateway methods), invoice picker (optional, searchable), payer fields (name, email, id — optional), description, submit "Record payment" with loading spinner.
- States: pristine → validation errors → submitting (button spinner, fields locked) → success (payment + receipt card) → failure (error banner + retry).
- Keyboard: amount numeric-decimal; payer email email type.
- Permission:
payments.process. - Analytics:
payments.new.submit,payments.new.success,payments.new.failed.
S4 — Refund sheet (bottom sheet)
- Purpose: full/partial refund with reason.
- Route: modal from S2.
- Widgets: refundable amount display, amount field (default = full), reason (required), submit, destructive styling.
- Errors: 409 messages inline/banner; refund amount > payable blocked client-side (min 0, max = amount − refundedAmount).
- Analytics:
payments.refund.submit,payments.refund.success,payments.refund.failed.
S5 — Reconcile sheet
- Purpose: set payment status from gateway outcome.
- Widgets: status selector (success → completed / failed → failed / other → pending), optional gateway response text, submit.
- Permission:
payments.reconcile. - Analytics:
payments.reconcile.submit.
S6 — Receipt view
- Purpose: display receipt (data row) for printing/sharing.
- Route:
/payments/receipts/:receiptId - Sections: header (receipt number, issuedAt), payment reference, amount/fee/currency, payer, description, invoice ref.
- Actions: Share/Print
(planned)via reports; Copy receipt number. - State: 404 empty state if missing.
- Analytics:
payments.receipt.view,payments.receipt.share.
S7 — Receipts list
- Purpose: browse all receipts (paged, newest first).
- Route:
/payments/receipts - Mirrors S1 with receipt rows; tap → S6.
- Analytics:
payments.receipts.view.
S8 — Payment context on invoice detail (fees module)
- Invoice detail shows payments tab/row list (
GET /payments/invoice/:invoiceId), paidAmount progress, "Record payment" entry point. Details owned by fees docs.
06 — Screen Specifications (Payments)
- S1 — Payments list
- S2 — Payment detail
- S3 — New payment form (full-screen)
- S4 — Refund sheet
- S5 — Reconcile sheet
- S6 — Receipt view
- S7 — Receipts list
- Global interaction details
The largest document: precise specs for each payments screen. All tokens referenced from 00-shared/02_Design_Tokens.md; components from 00-shared/03.
S1 — Payments list
Layout
AppBar: title "Payments", actions: receipts icon, search icon.- Body: search field (sticky), filter chips row (All / Completed / Pending / Processing / Failed / Refunded / Partially refunded — client-side), list, load-more footer.
- FAB:
AppFAB.extended"Record payment" — hides on scroll down. - Padding: 16 phone / 24 tablet+. Section gap 24. Card gap 12.
Row composition (AppListTile 72 dp)
- Leading: gateway icon (offline = cash icon) in
primaryContainercircle. - Title:
transactionReference(mono,titleSmall). - Subtitle:
payerName+ date (bodySmall,onSurfaceVariant). - Trailing: amount (
titleMedium, tabular figures) + statusAppBadge. - On tap: ripple + push S2.
Status badge mapping
| Status | Badge color (role) | Icon |
|---|---|---|
| completed | success | check_circle |
| pending / processing | info (primaryContainer) | schedule |
| failed | error | cancel |
| refunded / partially_refunded | tertiary | currency_rupee/replay |
States
- Loading: 8
AppSkeletonrows; shimmerm-slow. - Empty:
AppEmptyState(iconpayments, "No payments yet", CTA "Record payment"). - Error:
AppErrorStatecode-aware (403 → permission empty-state; 5xx → generic + requestId + Retry). - Offline:
AppOfflineBanner; cached last-good list (client cache 5 min). - Pagination: footer "Load more" → spinner → next page; "End of list"
bodySmallwhentotalreached. Server returns{data,total}— client computes hasNext locally.
A11y
- Row: single semantics node "Payment TXN-…, amount, status".
- Chips:
Semantics(selected:); results count live region. - Contrast per 00-shared/09; touch targets ≥ 48.
Animations
- New row entrance: fade+rise stagger 40 ms.
- Status chip change:
AnimatedSwitcher150 ms.
S2 — Payment detail
Layout
- Hero header card: amount
headlineMediumtabular; status badge; transaction referencemonobodySmallwith copy icon. - Info cards (each
AppCarde-1, padding 16, gap 12):- Payer: name, email, payerId.
- Invoice: link (route to invoice), status, paidAmount/totalAmount progress bar
(linear,
m-base). - Gateway: gateway, gatewayTransactionId, gatewayResponse.
- Money: amount, fee, refundedAmount, currency.
- Timeline: createdAt, updatedAt (from BaseSchema).
- Actions row (primary/secondary):
- Receipt (
FilledButton.tonal) → S6. - Refund (
OutlinedButtondestructive) → S4 (visible iffpayments.refund+ status completed). - Reconcile (
OutlinedButton) → S5 (visible iff PENDING/PROCESSING +payments.reconcile).
- Receipt (
States
- Loading: skeleton cards.
- 404:
AppErrorState"Payment not found" (no retry; back offered). - Conflict after action: banner with server message.
A11y
- Copy button: tooltip + semantics "Copy transaction reference"; announce "Copied".
- Amounts:
Semanticsnumeric value.
S3 — New payment form (full-screen)
Layout
AppBartitle "Record payment", close/back.Formcolumn (gap 16): amount*, currency, gateway*, gatewayTransactionId, invoice (searchableAppDropdown), payerName, payerEmail, payerId, description, submitFilledButtonfullWidth (48 dp).- Keyboard:
resizeToAvoidBottomInset; scroll into view on focus; amount → next, submit on last field (action "Done"). - Safe areas: bottom padding = nav/safe area + 16.
Components & tokens
- Amount:
AppTextFieldkeyboardTextInputType.numberWithOptions(decimal:true), formatter decimal 2 places, error "Enter a valid amount (min 0)". - Gateway:
AppDropdownfromPaymentGatewayenum — icons per method. - Invoice: optional; selecting prefills amount (editable after) + payerName from invoice payer when present.
- Submit:
loadingswaps label → spinner; fields disabled while pending; double-tap blocked.
States
- Validation: server 400
detailsmapped to fields; generic banner for others. - Success: navigate-inline to success view: payment + receipt cards + actions (View receipt, Done → S1 refreshed).
- Failure: banner
AppBannererror + Retry; form values preserved.
Dark/light: token-based only.
A11y
- Labels visible; autofill hints (name, email).
- Error announcement via live region; focus first invalid field.
S4 — Refund sheet
AppBottomSheetwith drag handle; title "Refund payment".- Refundable amount info row (
bodyMedium). - Amount field (default full refundable, min 0.01, max refundable).
- Reason
AppTextField(required, 3–200 chars). - Submit
FilledButtondestructive (error colors) loading state; CancelTextButton. - Server 409 messages → inline error under amount / banner.
- Confirm semantics:
Semantics(button, label "Confirm refund of amount"). - Sheet dismiss: back gesture/ESC = cancel; no discard warning (non-destructive form).
S5 — Reconcile sheet
- Title "Reconcile payment".
- Outcome
AppRadiogroup: Success / Failed / Pending. - Gateway response text field (optional).
- Submit → PATCH; result status reflected on S2; errors → banner + retry.
S6 — Receipt view
- Receipt card (centered, max width 480, e-1, padding 24):
- Header: "Receipt" +
receiptNumbermono; school/org name (tenant branding)(planned). - Body: amount large, fee, currency, payment method; payer name/email; description; payment reference; invoice id.
- Footer: issuedAt date (locale), paymentId.
- Header: "Receipt" +
- Actions: Share
(forward-looking); Copy receipt number. - Print:
(planned)reports module PDF. - A11y: receipt is a
Semanticssummary; amounts spoken fully.
S7 — Receipts list
- Mirrors S1; rows: receiptNumber, amount, date, payerName; tap → S6.
- Pagination same
{data,total}handling.
Global interaction details
- Haptics: success
lightImpact; errormediumImpact; refund confirmheavyImpact. - Hover (desktop): row tint
surfaceContainerHigh; destructive hovererrorContainer. - Focus order: search → chips → list → FAB; visible
primaryring. - Hero: invoice → payment amount cross-fade (no shared element needed).
- Reduced motion: all
m-instantfades.
07 — Component Library (Payments)
- P1 — PaymentRow
- P2 — AmountText
- P3 — StatusBadgePayments
- P4 — ReceiptViewCard
- P5 — PaymentFormFields
- P6 — ActionsRow
- P7 — MoneyProgress
Reusable components for the payments module. Shared primitives (
AppCard,AppTextField,AppDropdown,AppBadge,AppSnackbar,AppEmptyState,AppErrorState,AppSkeleton,AppFAB) come from 00-shared/03 — not re-specified here.
P1 — PaymentRow
- Purpose: list row for a payment.
- Props:
payment,onTap,showInvoiceLink?. - Variants: default; dense (accountant list).
- States: regular, selected, disabled (never — read-only).
- Contents: gateway icon,
transactionReference, payerName, date, amount, status badge. - A11y: single semantics node; status icon+label, never color-only.
- Tokens:
e-0, height ≥ 72,monoreference. - Flutter:
ListTilecustom;CachedNetworkImagenot needed (no images).
P2 — AmountText
- Purpose: money display with currency, tabular figures.
- Props:
amount,currency,size(titleMedium/headlineMedium/bodyMedium),signed?. - States: default; strikethrough for refunded portion; dim for failed/pending.
- Validation: formats 2 decimals; locale via
IntlNumberFormat.currency. - Flutter:
Text+FontFeature.tabularFigures(); util extension.
P3 — StatusBadgePayments
- Purpose: payment status visual (wraps
AppBadge). - Props:
status(PaymentStatus),showLabel. - States: 7 statuses → color+icon per 06 §S1 table.
- A11y: label always spoken.
- Flutter:
Badge/custom chip.
P4 — ReceiptViewCard
- Purpose: printable receipt layout.
- Props:
receipt,actions?. - States: complete; missing invoice (still renders, invoice ref blank).
- Tokens:
surfaceContainerLow,e-1,r-md, padding 24, max width 480. - Flutter:
Card+Column.
P5 — PaymentFormFields
- Purpose: the record-payment form body (extracted, reusable on S3 and as a sheet on tablet).
- Props:
initialAmount,invoices(for picker),busy,onSubmit(payload). - Variants: full (all fields) / compact (invoice context prefills).
- Validation: server-mapped errors (
details) + client (amount min, email format, gateway required). - Flutter:
Form+AppTextFields.
P6 — ActionsRow
- Purpose: contextual payment actions (View receipt / Refund / Reconcile).
- Props:
status,permissions,onRefund,onReconcile,onReceipt. - States: action hidden when permission absent or status ineligible; destructive
button styled
error. - Flutter:
Row/WrapofFilledButton.tonal,OutlinedButton,TextButton.
P7 — MoneyProgress
- Purpose: paidAmount vs totalAmount progress (invoice link).
- Props:
paid,total,label. - States: 0%, partial, 100% (PAID — success color).
- A11y: live summary "paid X of Y".
- Flutter:
LinearProgressIndicator+Text.
08 — Form Specifications (Payments)
- F1 — Record payment (
POST /payments) - F2 — Refund (
POST /payments/refund) - F3 — Reconcile (
PATCH /payments/:transactionRef/reconcile) - F4 — Local filters (S1)
Every form field, derived from
process-payment.dto.ts,refund-payment.dto.ts, and the reconcile endpoint body.
F1 — Record payment (POST /payments)
| Field | Type / control | Label | Hint / placeholder | Keyboard | Validation (server) | Error message |
|---|---|---|---|---|---|---|
| amount | AppTextField decimal | Amount | e.g. 250.00 | numeric-decimal | @IsNumber @Min(0) | "Enter a valid amount (min 0)" |
| currency | AppTextField (read-only default) | Currency | USD | text | @IsString @IsOptional | — |
| gateway | AppDropdown | Gateway | required | — | @IsEnum(PaymentGateway) | "Select payment method" |
| gatewayTransactionId | AppTextField | Gateway reference | optional, gateway methods | text | @IsString @IsOptional | — |
| invoiceId | AppDropdown searchable | Invoice | optional (search by invoice no.) | — | @IsString @IsOptional | — |
| invoiceType | AppTextField | Invoice type | optional | text | @IsString @IsOptional | — |
| payerId | AppTextField | Payer ID | optional | text | @IsString @IsOptional | — |
| payerEmail | AppTextField | Payer email | @IsString @IsOptional | "Enter a valid email" (client) | ||
| payerName | AppTextField | Payer name | optional | text | @IsString @IsOptional | — |
| description | AppTextField multiline | Description | optional | text | @IsString @IsOptional | — |
- Defaults: currency
USD; amounts empty; gateway none. - Dependencies/conditional: selecting an invoice prefills amount + payerName (editable). Offline methods (cash/cheque/bank_transfer) recommend payerName; gateway methods show gatewayTransactionId.
- Autocomplete: payer name/email from recent payers (client cache)
(proposed). - Submission: client validates →
POST /payments; button loading; no optimistic update (server is source of truth). - Success: inline payment + receipt cards; offer "View receipt", "Done".
- Failure: 400 field errors mapped; 409 conflict banner + refresh; 5xx generic + retry; values preserved.
- Undo: not applicable (confirmatory commitment).
F2 — Refund (POST /payments/refund)
| Field | Label | Validation | Notes |
|---|---|---|---|
| paymentId | hidden (from S2 context) | @IsString | locked |
| amount | Refund amount | @IsNumber @Min(0) @IsOptional; max = amount − refundedAmount | default full refundable; 0 → inline error |
| reason | Reason | @IsString @IsOptional; client min 3 chars | show in confirmation |
- Submission: loading; on success snackbar + status updated; on 409 "Only completed payments can be refunded."/"Refund amount exceeds payment amount." → inline/banner.
- Optimistic: no.
F3 — Reconcile (PATCH /payments/:transactionRef/reconcile)
| Field | Type | Validation |
|---|---|---|
| status (body) | AppRadio | "success"→completed, "failed"→failed, else pending |
| gatewayResponse (optional client field) | text | informational |
- Submission: loading; result status reflected after refresh.
- Error 404: payment not found → error state.
F4 — Local filters (S1)
- Search term + status chips — client-side only (no server query support).
- Debounce 300 ms; clear restores.
- No validation.
09 — User Behaviour (Payments)
- Expected behaviour
- Common mistakes
- Fast paths
- Power users
- New users
- Expert users
- Accessibility users
- Interrupted sessions & retries
- Abandonment
- Frustration points + fixes
Expected behaviour, mistakes, fast paths, and how the UX resolves them.
Expected behaviour
- Accountant records a counter payment in < 60 s and sees a receipt instantly.
- Refunds are deliberate: reason required, exact amount shown, confirmation.
- Gateway outcomes surfaced as PENDING/PROCESSING/COMPLETED/FAILED, reconciled by the accountant, not auto-patched by clients.
- Invoice due amounts always reflect latest
paidAmount(pull-to-refresh).
Common mistakes
| Mistake | UX mitigation |
|---|---|
| Wrong payer selected | Payer search prefilled from invoice; editable before submit; detail shows full payer context |
| Wrong amount | Decimal formatter + min 0 + server validation; no silent rounding |
| Double-tap Submit → double payment | Submit disabled while pending; but no idempotency key server-side — flag OQ |
| Refunding too much | Client max = amount − refundedAmount; server 409 backstop |
| Recording gateway payment without gateway reference | Optional field lent; reconcile path exists |
| Reconciling with wrong status string | Radio limits choices; server maps unknown → pending |
Fast paths
- Invoice detail → "Record payment" prefills amount + payer.
- FAB from list → new payment.
- Recent payment repeated → copy previous (payer + description)
(proposed).
Power users
- Keyboard-first desktop:
/focus, Enter submit, ESC cancel, tab order fixed. - Keyboard shortcuts:
Nnew payment;Ctrl/Cmd+Ffilter.
New users
- First-time empty state explains "record your first payment"; help text on refund sheet explains guards.
Expert users
- Reconcile screen aggregates PENDING payments count; bulk-eligible design
(proposed)— today one PATCH per payment.
Accessibility users
- Money values spoken fully; statuses icon+label; focus order logical; large type reflows (no fixed heights).
Interrupted sessions & retries
- Interrupted mid-submit: button pending until server responds; on failure keep form intact; never half-write locally.
- Retries: exponential backoff for 5xx/network; 429 → countdown, no auto-retry.
Abandonment
- Close form → confirm discard (if touched); refund sheet discard without warning (non-destructive until submit).
Frustration points + fixes
| Frustration | Fix |
|---|---|
| "Where did my payment go?" | Traceable: payment → invoice → receipt links; search |
| Invoice status stale after payment | Auto-link to invoice updates PAID/PARTIAL; pull-to-refresh |
| Can't fix a wrong gateway outcome | Reconcile screen sets status explicitly |
| Money precision doubt | Tabular figures + 2-decimal format; show refundedAmount explicitly |
| Two payment modules confusing (fees payments vs payments_v2) | Cross-module inconsistency flagged OQ-1 (see 12); UI shows canonical payments module records |
| 404 instead of validation on business rule | Client pre-validates marks/amounts; 404s handled as not-found UI when weird codes arrive—log + report |
10 — Interaction Specification (Payments)
- Tap
- Long press
- Double tap
- Swipe
- Pull-to-refresh
- Scroll & infinite scroll
- Back / hardware back / ESC
- Hover / mouse / desktop
- Gamepad
- Haptics
- Motion
Interaction detail for the payments module. Base gestures from 00-shared/08.
Tap
- Row tap → detail push (
m-base). FAB tap → new payment. Action buttons tap → submit/confirm. - Copy reference → clipboard + snackbar "Copied".
Long press
- Payments row → context menu (View, Receipt, Refund, Reconcile).
- Receipt summary → copy menu.
Double tap
- Not used (amounts/cards are static; enabling double-tap has no value).
Swipe
- Right-swipe on COMPLETED row: quick "New receipt view"; swipe on PENDING row:
"Reconcile"
(proposed). - No destructive left-swipe (refund is deliberate, not a swipe).
Pull-to-refresh
- Payments list, Receipts list, Payment detail — re-fetch
{data,total}/ payment / receipt;primaryspinner.
Scroll & infinite scroll
- Payments list is paged (Load more footer). No auto infinite scroll.
- FAB hides on scroll-down, reappears scroll-up.
Back / hardware back / ESC
- Detail → list; form → previous; sheets/dialogs: back/ESC = cancel (no discard warning on sheets).
- On root payments tab: Android back → previous shell branch.
Hover / mouse / desktop
- Row hover tint
surfaceContainerHigh; destructive action hovererrorContainer. - Right-click = context menu (same as long-press).
- Focus ring
primaryon all interactive; Enter activates focused control.
Gamepad
- Not relevant (mobile/desktop ERP client).
Haptics
- Success record →
lightImpact; refund confirmed →heavyImpact; error →mediumImpact.
Motion
- Success transition: sheet→inline success with
m-entranceslide; receipt cardAnimatedSwitcher150 ms. - Money updates:
AnimatedDefaultTextStyleorAnimatedSwitcher(fast, 150 ms).
11 — Design System Mapping (Payments)
Maps payments components to Material 3 + Flutter widget + tokens. Reference 00-shared/04 for the global mapping.
Component mapping
| Component | M3 role | Flutter widget | Spacing | Typography | Elevation | Radius | Colors |
|---|---|---|---|---|---|---|---|
| PaymentRow | List item | ListTile custom | h ≥ 72, gap 12 | titleSmall ref | e-0 | r-md | surface / onSurface |
| RefundText | Money display | Text + tabular | — | titleMedium | — | — | onSurface |
| StatusBadge | Badge | Chip/Badge | — | labelMedium | e-0 | r-full | per status on *Container |
| ReceiptViewCard | Elevated card | Card | padding 24 | body/body | e-1 | r-lg | surfaceContainerLowest |
| PaymentActionsRow | Action row | Wrap of buttons | gap 8 | labelLarge | — | r-md | primary/error |
| MoneyProgress | Progress | LinearProgressIndicator | — | bodySmall | — | r-sm | primary/success |
| NewPaymentForm | Form | Form+AppTextField | gap 16 | bodyMedium | e-1 | r-md | surfaceVariant |
| Reconcile radios | Radio group | RadioListTile | — | bodyMedium | — | — | primary |
Iconography
- Offline gateways: cash/cheque/bank_transfer.
- Gateway logos (generic icons; no brand logo w/o asset)
(forward-looking). - Status icons: check_circle, schedule, cancel, replay.
Motion
- Page transitions
m-base; sheetm-base; FABm-entrance; money updates 150 msm-fast; reduced motion → fades.
Responsive
- Phone: single column, FAB, bottom nav.
- Tablet (600–839): two-column payment list; detail in master pane.
- Landscape tablet / desktop: table layout (columns: ref, payer, gateway, amount, status, date), filter rail, hover actions.
12 — API Mapping (Payments)
- E1 — Process payment (S3)
- E2 — Refund (S4)
- E3 — Reconcile (S5)
- E4/E7 — Lists (S1/S7)
- E5/E8 — Detail
- Realtime
- Caching & offline
Exact endpoints from
payments.controller.ts, mapped to screens. Wire contract from 00-shared/07 — note the pagination deviation below.
Base: /api/v1 · Auth: JwtAuthGuard (RBAC perms (planned)).
| # | Method | Path | Screen | Notes |
|---|---|---|---|---|
| E1 | POST | /payments | S3 record | body ProcessPaymentDto; returns {payment, receipt}; emits PaymentProcessed |
| E2 | POST | /payments/refund | S4 | body RefundPaymentDto; 409 rules |
| E3 | PATCH | /payments/:transactionRef/reconcile | S5 | body {status:string} |
| E4 | GET | /payments?page&limit | S1 | returns {data, total} — no shared meta |
| E5 | GET | /payments/:id | S2 | 404 if missing |
| E6 | GET | /payments/invoice/:invoiceId | S8 | array (unpaginated) |
| E7 | GET | /payments/receipts?page&limit | S7 | {data, total} |
| E8 | GET | /payments/receipts/:id | S6 | 404 if missing |
E1 — Process payment (S3)
- Request:
{ "amount": 250, "currency": "USD", "gateway": "cash",
"gatewayTransactionId": "G-123", "invoiceId": "…", "payerName": "A. Kumar",
"payerEmail": "a@x.com", "description": "Term 1 fee" }
- Response (data):
{ payment: {transactionReference, amount, fee, refundedAmount, currency, gateway, status:"completed", invoiceId, payerName, payerEmail, createdAt}, receipt: {receiptNumber, paymentId, amount, fee, currency, paymentMethod, payerName, issuedAt} } - Loading: submit spinner → success screen. Caching: none (write).
- Optimistic UI: none — server-confirmed only.
- Errors: 400 validation (field
details), 409 duplicates, 5xx. - Idempotency:
transactionReferenceauto-generated server-side; no client Idempotency-Key (OQ-3).
E2 — Refund (S4)
- Request:
{paymentId, amount?, reason?} - Rules (service
payments.service.ts): status must becompleted(409); refund total ≤ amount (409); full →refunded, partial →partially_refunded. - Response: updated payment doc.
E3 — Reconcile (S5)
- Request:
PATCH /payments/{transactionRef}/reconcilebody{status}—"success"→ completed,"failed"→ failed, anything else → pending. - Response: updated payment.
E4/E7 — Lists (S1/S7)
- Query:
page(default 1),limit(default 20). - Response shape deviation:
{data:[…], total}— the shared envelope addsmetaonly for{data, meta}payloads (response-envelope.interceptor.ts:25-32), so the client must derive pagination fromdata.length+total. Documented as OQ-2 (inconsistent withPaginationMetaelsewhere). - Sort: server-fixed
createdAt/issuedAtdesc;sort/qparams not supported.
E5/E8 — Detail
- Returns single doc; 404 →
RESOURCE_NOT_FOUND.
Realtime
PaymentProcessed/PaymentRefundedevents →WsBridgebroadcasts totenant:{tenantId}room (ws-bridge.service.ts) → client can live-append list / refresh invoice dues. Not routed to BullMQ (event-queue-map.tshasPaymentCompleted→emails dead entry;PaymentProcessedhas no entry).
Caching & offline
- Lists: client cache 5 min (stale-while-revalidate); detail 1 min.
- Offline: read-only cached views + banner; writes blocked (no offline payment queue — money path never optimistic).
13 — State Management (Payments)
Bloc/Cubit per screen following 00-shared/06 conventions (proposed architecture).
Cubits
PaymentsListCubit
- State:
{LoadState, List<PaymentDto>, total, page, hasNext, filters: (status, query), busy}. - Events:
LoadList,Refresh(bypass cache),LoadMore,FilterChanged,Retry,PaymentCreated(append/refresh). - Behaviours: server returns
{data,total}→hasNext = data.length + current < total; client-side filtering via local search + status chips; pull-to-refresh re-fetches. - Realtime: subscribes
tenantWS room, filter topicpayment.processed/payment. refunded→ refresh or insert.
PaymentDetailCubit
Load(id)→{data, LoadState}; on WS event for sametransactionReference→ re-fetch.- Actions emit
Refund(submit)→ success → reload + snackbar;Reconcile(status)→ reload.
NewPaymentCubit
- State machine (Mermaid):
stateDiagram-v2
[*] --> Idle
Idle --> Submitting : RecordPayment(valid payload)
Submitting --> Success : server {payment,receipt}
Submitting --> Idle : validation/network error
Success --> [*]
- No optimistic state; values preserved across
Failed. - Uploads: none (no file uploads in payments).
ReceiptsListCubit
- Mirrors PaymentsList (paged
{data,total}).
Shared selectors
InvoiceStatusSelector(from fees) — used on MoneyProgress.PermissionsSelector— gates action visibility.
Cache
sl:payments:list:{page}5 min;sl:payments:{id}1 min; refresh bypasses.- Clear payments cache when an invoice reaches PAID (fees event) so dues views refresh.
Realtime
- WS channel
tenant:{tenantId}: onPaymentProcessed→ refresh invoice dues + list (debounced 500 ms).
Error handling (client table)
| Code | Behaviour |
|---|---|
| 400 | map details → fields |
| 409 | banner with server message; refresh list/detail |
| 404 | empty/not-found state |
| 429 | countdown; disable submit |
| 5xx/network | retry UI; form preserved |
14 — QA Checklist (Payments)
- Functional
- Concurrency / integrity
- Visual
- Accessibility
- Performance
- Offline / network
- Security
- Localization
- Animation
Module-specific checks layered on 00-shared/10_QA_Baseline.md.
Functional
-
Record cash payment → receipt generated, invoice
paidAmountupdates to PAID/PARTIAL. - Payment without invoiceId → succeeds, no invoice link, no crash.
-
Partial then full refund →
partially_refunded→refunded;refundedAmountsums. - Refund on non-completed → 409 message, no state change.
- Refund exceeding amount → 409, blocked.
- Reconcile success/failed/unknown → completed/failed/pending mapping.
- Reconcile nonexistent ref → 404.
-
Lists paginate with
{data,total}; last page shows "end". - GET by invoice returns only that invoice's payments (tenant-scoped).
- Receipt view renders full data incl. fee/currency/paymentMethod/issuedAt.
- Payment list filters (status/search) work client-side across pages.
- Receptions: offline publishes payment → UI blocked (never optimistic).
Concurrency / integrity
- Double-tap submit produces exactly one payment (flag OQ-3: no idempotency key).
-
Two concurrent refunds don't exceed
refundedAmount(server 409 check). -
sumByInvoiceonly countscompletedpayments. - Transaction reference + receipt numbers unique (report duplicates = bug).
Visual
- Light/dark token-only colors; status colors meet contrast (non-colorblind safe).
- Money tabular figures; currencies formatted locale.
- Responsive phone/tablet/desktop reflow; FAB hide/show.
Accessibility
- TalkBack/VoiceOver reads full money amounts + status labels.
- Focus order on forms; Enter submits; ESC cancels sheets.
- Dynamic type 2×: cards/lists reflow, no clipping.
- Errors announced via live region; focus to first invalid field.
Performance
- List first frame ≤ 300 ms cached; ≤ 2 s network.
- Pagination doesn't drop frames; money rendering stable.
- No duplicate request spikes on pull-to-refresh.
Offline / network
- Offline banner on all screens; cached lists render; writes blocked with clear copy.
- Network loss mid-submit shows failure + retry; no duplicate server money movement after retry (verify server dedup — OQ).
Security
- Cross-tenant payment id access → 404 (repository tenant-scope).
-
Refund/reconcile hidden without permission (client) and enforced server
(planned). - No payer PII in analytics event payloads.
- Gateway refs not logged to crash logs.
Localization
- En + candidate locales numbers/dates; RTL announcement-ready.
- Long translated strings don't break receipt card.
Animation
- Transitions use tokens; reduced-motion respected; no dropped frames on success transition.
15 — Flutter Implementation Guide (Payments)
How Flutter developers build the payments feature. Base architecture: 00-shared/11.
Folder structure
features/payments/
├── data/
│ ├── dto/payment_dto.dart # fromJson/toJson (envelope payload)
│ ├── dto/receipt_dto.dart
│ ├── dto/process_payment_request.dart
│ ├── models/payment.dart # domain: status enum, gateway enum, money
│ ├── models/receipt.dart
│ └── repositories/payment_repository.dart
├── presentation/
│ ├── cubit/payments_list_cubit.dart
│ ├── cubit/payment_detail_cubit.dart
│ ├── cubit/new_payment_cubit.dart
│ ├── cubit/receipts_list_cubit.dart
│ ├── pages/payments_list_page.dart
│ ├── pages/payment_detail_page.dart
│ ├── pages/new_payment_page.dart
│ ├── pages/receipt_view_page.dart
│ ├── pages/receipts_list_page.dart
│ └── widgets/ (payment_row, amount_text, status_badge, receipt_view_card,
│ payment_form_fields, actions_row, money_progress)
Key implementation notes
DTO/model mapping
PaymentDto.fromJsonparses snake_case payload; mapstatus/gatewayto enums with unknown-value fallback (unknown).- Money: keep server
doubleinternally BUT format viaNumberFormat.currency; never do arithmetic with floats where avoidable — round to 2 decimals on display. Document minor-units migration as future work. Paymentmodel exposes:refundableAmount = amount - refundedAmount.
Repository
fetchPayments(page, limit)→ parse{data,total}(notmeta— deviation!).processPayment(ProcessPaymentRequest)→ returns(Payment, Receipt).refundPayment(paymentId, amount?, reason?).reconcilePayment(ref, status).fetchReceipts(page, limit),fetchReceipt(id),fetchByInvoice(invoiceId).- Throws typed
ApiException(code, status, details)viaAppDiointerceptors.
Cubits
PaymentsListCubitextendsPaginatedListMixin<Payment>(client-side filter keepsList<Payment> filteredderived in UI or viaemitter).NewPaymentCubit:Idle/Submitting/Success/Failed; on Success emitPaymentCreatedto refresh list + invoice.- WS:
WsClient.subscribe('payment.processed')→ debounce reload.
Navigation
- Routes:
/payments,/payments/new,/payments/:id,/payments/receipts,/payments/receipts/:id. - Deep links (forward-looking) mapped in
AppRouter. - Guard:
permissionGuard('payments.read')on routes; action visibility via permissions selector.
Money formatting
- Extension
double.toMoney(currency, locale)→NumberFormat.currency(locale: locale, name: currencyCode); tabular figures.
Localization
- Keys:
payments.*(list.title, record.title, receipt.title, errors., status.).
Testing
- Unit:
PaymentMapperTest,RefundRulesTest(max/partial/full),CubitTest(list pagination + filters, new-payment state machine, 409 mapping). - Widget: S1 states (loading/empty/error/data), S3 validation + success view, S4 sheet guards.
- Golden: PaymentRow, StatusBadge (7 states), ReceiptViewCard — light/dark, 3 sizes.
- Integration: record cash payment against prefilled invoice → receipt visible → invoice status PAID.
Performance
ListView.builder; rowsconst-friendly;RepaintBoundaryon receipt card + charts; pagination via footer button not scroll listener (server contract).- Avoid rebuilding filter chips per keystroke (debounce 300 ms).
Gotchas from source
GET /paymentsand/payments/receiptsreturn{data, total}— client pagination derived manually; do not assumemeta.PaymentProcessedis WS-broadcast but not BullMQ-routed — realtime refresh is the only push signal; receipt email (PaymentCompleted→emails) is a dead map entry.- Receipt numbers derive from an in-process counter — do not rely on monotonicity across instances.
01 — Product Overview (RBAC Module)
- 1. Purpose
- 2. The guard pipeline (derived, exact)
- 3. Domain model (derived)
- 4. Default roles (seeded, immutable)
- 5. Permission catalog (complete, quoted from source)
- 6. Business goals
- 7. Edge cases (derived from source)
- 8. Success metrics (proposed)
- 9. PRD conflict (flagged)
- 10. Module assumptions
- 11. Open questions (module; global ledger in
00-shared/12) - 12. Glossary (module)
StudyLyon — multi-tenant ERP / School Management API. This package designs the RBAC module client (Flutter, forward-looking spec) against the implemented NestJS backend. Every endpoint, DTO field, schema, permission name, and guard behaviour is derived from
src/modules/rbac/**,src/modules/auth/**,src/modules/organizations/**,src/common/**, andsrc/app/app.module.ts. No feature is invented; gaps are flagged in the Assumptions & Open Questions section (and mirrored in 00-shared/12).
1. Purpose
RBAC (role-based access control) is the authorization spine of StudyLyon. Auth answers who you are; RBAC answers what you may do, inside which tenant. The module owns:
| Responsibility | Source |
|---|---|
| Catalog of all 95 permission names (single source of truth) | permissions.constants.ts:1-97 ALL_PERMISSIONS |
| Role definitions (7 seeded system roles + unlimited tenant custom roles) | role.schema.ts:8-65 DEFAULT_ROLES; rbac.service.ts:83 createRole |
| Role → permission resolution at runtime (per-tenant) | rbac.service.ts:59-65 findBySlugsUnscoped + flatMap |
| Organization membership (user ↔ org ↔ roles) | organization-member.schema.ts:13-44; rbac.service.ts:113-140 |
| Permission enforcement on every endpoint (global guard) | app.module.ts:129-133; rbac.guard.ts:20-52 |
| Fast permission lookup via Redis (5-minute TTL) | rbac.service.ts:48,68 |
| Default-role seeding at tenant bootstrap | auth.service.ts:93; organizations.service.ts:58 |
2. The guard pipeline (derived, exact)
Every HTTP request passes the three global APP_GUARDs registered in
app.module.ts:129-133, in this order:
Request
↓
RateLimitGuard (app.module.ts:129) — Redis sliding window; default tier 'api' 100/min
↓
JwtAuthGuard (app.module.ts:130) — Bearer token → JwtPayload {sub, tenantId, roles, type}
↓
RbacGuard (app.module.ts:131) — roles (OR) + permissions (AND) from metadata
↓
Controller → Service → Repository (tenant-scoped) → MongoDB
JwtAuthGuard(jwt-auth.guard.ts:40-55) verifies the token, setsreq.user = { id, tenantId, roles }, and populates theTenantContextService(isPlatformAdmin=roles.includes('platform_admin'),jwt-auth.guard.ts:54).RbacGuard(rbac.guard.ts:20-52) reads@Roles()/@Permissions()metadata:- no metadata → allow (
rbac.guard.ts:29); - roles → pass if any required role is in the JWT claim (
rbac.guard.ts:39-41); - permissions → pass only if every required permission is in the resolved set
(
rbac.guard.ts:43-49, AND semantics); resolution hits the Redis cachesl:{tenantId}:perm:{userId}with 300 s TTL, then falls back to the DB (rbac.service.ts:44-73); - failure →
ForbiddenException→ 403PERMISSION_DENIED(http-exception.filter.ts:30).
- no metadata → allow (
The RBAC controller itself re-declares @UseGuards(JwtAuthGuard, RbacGuard) and is
class-gated with @Roles('org_admin') (rbac.controller.ts:21-22) — meaning today
only users holding the org_admin role can manage roles/members, regardless of the
rbac.* permissions that exist in the catalog. See OQ-R1.
3. Domain model (derived)
User (users collection)
└── organization_members: 1:1 per (tenantId, userId) [organization-member.schema.ts:48]
└── roles: string[] of role slugs [organization-member.schema.ts:22]
└── roles collection: slug → permissions[] [role.schema.ts:84-85]
(unique per tenant: {tenantId, slug}) [role.schema.ts:89]
- Every business doc carries
tenantId(base.schema.ts:11); repositories inject it structurally (base.repository.ts:20-30) — cross-tenant reads are impossible at the repo layer. - Member statuses exist (
invited | active | suspended,organization-member.schema.ts:7-11) but the service only ever createsactive(rbac.service.ts:122); no endpoint changes status (OQ-R3). - Roles are soft-deleted (
rbac.service.ts:106); members are soft-deleted (rbac.service.ts:139).
4. Default roles (seeded, immutable)
role.schema.ts:8-65 — seeded by seedDefaults at registration (auth.service.ts:93)
and at organization creation (organizations.service.ts:58, note: seeded under
org.slug as tenantId, OQ-R6). All seven are isSystem: true — the API refuses update
or delete (rbac.service.ts:94-95,104-105).
| Name | Slug | Priority | Permissions | Locked |
|---|---|---|---|---|
| Platform Admin | platform_admin | 1000 | [] (cross-tenant; bypasses tenant scope via base.repository.ts:21) | system |
| Organization Admin | org_admin | 100 | all 95 | system |
| Teacher | teacher | 50 | student.read, attendance.mark, attendance.edit | system |
| Staff | staff | 40 | student.read | system |
| Accountant | accountant | 30 | fees.collect, student.read | system |
| Parent | parent | 20 | student.read | system |
| Student | student | 10 | [] | system |
Roles list is server-sorted by priority: -1 (rbac.service.ts:76) — priority also acts
as a rough "power" ordering the UI uses for display.
5. Permission catalog (complete, quoted from source)
ALL_PERMISSIONS, permissions.constants.ts:1-97 — the only source of permission
names. 95 permissions. The client mirror must be generated from this list (see
13_State_Management.md); the full UI matrix is in 06_Screen_Specifications.md §3.
organization.read organization.update organization.delete
organization.settings.update user.read user.create
user.update user.delete user.import
rbac.role.read rbac.role.create rbac.role.update
rbac.role.delete rbac.member.read rbac.member.create
rbac.member.update rbac.member.delete staff.read
staff.create staff.update staff.delete
department.manage designation.manage student.read
student.create student.update student.delete
attendance.mark attendance.edit fees.collect
notification.read notification.update crm.read
crm.lead.manage crm.campaign.manage dashboard.read
dashboard.widget.manage report.generate report.read
biometric.log.create biometric.log.read biometric.device.manage
timetable.read timetable.create houses.read
houses.create houses.update houses.delete
rooms.read rooms.create rooms.update
rooms.delete audit.read books.read
books.create books.update books.delete
books.issue books.return fines.pay
transport.vehicle.read transport.vehicle.create transport.vehicle.update
transport.vehicle.delete transport.route.read transport.route.create
transport.route.update transport.route.delete transport.driver.read
transport.driver.create transport.driver.update transport.driver.delete
transport.assign settings.read settings.update
settings.delete feature-flags.read feature-flags.update
feature-flags.delete payments.read payments.process
payments.refund payments.reconcile receipts.read
file.read file.upload file.delete
webhook.create webhook.read webhook.update
webhook.delete search scheduler.read
scheduler.create scheduler.delete
Forward-looking (planned, not in source): docs/IMPLEMENTATION_PLAN.md:751-761
adds coaching permissions batch.manage, session.manage, test_series.manage, dpp.manage, study_material.manage, analytics.view, question_bank.manage, and
:713-749 adds roles batch_coordinator, test_coordinator, content_manager, coaching_student. None exist in permissions.constants.ts today — the client must
not render them until the backend ships them; the matrix renders only the 95 above.
6. Business goals
| Goal | Measure |
|---|---|
| Least-privilege out of the box | 7 seeded roles cover school staff; custom roles are tenant-scoped |
| Enforcement everywhere | Global RbacGuard on all routes; @Roles/@Permissions decorators (roles.decorator.ts:3, permissions.decorator.ts:3) |
| Fast enforcement | Permission resolution cached 300 s in Redis (rbac.service.ts:68); JWT roles are claim-carried (no DB hit on role checks) |
| Tenant isolation | tenantId injected in every query; unique {tenantId, slug} / {tenantId, userId} (role.schema.ts:89, organization-member.schema.ts:48) |
| Auditability | RBAC writes surface in audit-logs (Phase-5 permission audit (planned), docs/IMPLEMENTATION_PLAN.md:241; audit.read perm exists) |
| No accidental lockout of admins | system roles immutable; org_admin can never be deleted/edited |
7. Edge cases (derived from source)
- Custom role created without
isSystem:falsebecomes system-locked: schema default isisSystem: true(role.schema.ts:78-79) andupdateRole/deleteRolerefuse system roles (rbac.service.ts:94-95,104-105). The client must always sendisSystem:falsefor custom roles (OQ-R2). - Duplicate role slug → 409
ConflictException(rbac.service.ts:86-87; unique indexrole.schema.ts:89). PATCH /rbac/members/:idtakesAddMemberDto(requiresuserIdandroles) but the service ignoresuserId(rbac.controller.ts:71-72;rbac.service.ts:129-136).addMemberfor an already-member user hits the unique index → Mongo duplicate-key error → 500 (no 409 mapping inhttp-exception.filter.ts:47-55); client treats as conflict (OQ-R5).removeMemberon unknown id is silent success (no 404;rbac.service.ts:138-140).- Permission set is not validated against
ALL_PERMISSIONSserver-side (create-role.dto.ts:34-37@IsArrayonly) — the client matrix is the guard rail. - Roles in the JWT are minted at login (
auth.service.ts:145-154) and carried unchanged by refresh (auth.service.ts:192-196) — role changes don't take effect until re-login; permission changes take effect ≤ 300 s via cache expiry (OQ-R4). platform_adminhas no permissions and is notorg_admin— cannot access/rbac/*today; but it bypasses tenant scoping in repositories (base.repository.ts:21).
8. Success metrics (proposed)
- Role creation → first member assigned in < 2 min (admin goal).
- Permission matrix save round-trip < 1.5 s p95 (single PATCH, no per-cell writes).
- Zero reported cross-tenant role/member reads (QA 14).
- 100% of endpoints covered by
@Roles/@Permissionsafter Phase-5 audit ((planned),docs/IMPLEMENTATION_PLAN.md:241) — today only webhooks/files/scheduler/ search controllers use@Permissions(see12_API_Mapping.md §4).
9. PRD conflict (flagged)
- PRD FR-AUTH-07 (
PRODUCT_REQUIREMENTS_DOCUMENT.md:75): "RBAC enforces role + permission checks on every endpoint." Today the global guard runs everywhere, but only four controllers carry@Permissions(webhooks, files, scheduler, search;webhooks.controller.ts:22-86,files.controller.ts:30-67,scheduler.controller.ts:24-38,search.controller.ts:16); every other controller (students, fees, audit, …) is either ungated or role-gated only. The Phase-5 "Permissions audit (all endpoints)" (docs/IMPLEMENTATION_PLAN.md:241) is(planned). - PRD §8 (
PRODUCT_REQUIREMENTS_DOCUMENT.md:144): native mobile apps are out of Phase-1 scope ("web-first"). Per global ledger00-shared/12A1, this package specs the full Flutter client anyway (owner decision, documented, not a contradiction).
10. Module assumptions
- Client is forward-looking: backend is complete and authoritative; this package is the UI-side spec.
- The permission matrix is rendered from the server (
GET /rbac/permissions) — the client never hardcodes the 95 names as a second source of truth; the quoted list above is the current snapshot. - Membership ≠ account: adding a member requires an existing
usersdoc (add-member.dto.ts:6@IsMongoId); the members list does not join user profiles (rbac.service.ts:109-111) — display names require a paralleluser.readfetch or themetadatafield (OQ-R7).
11. Open questions (module; global ledger in 00-shared/12)
| # | Item | Impact |
|---|---|---|
| OQ-R1 | /rbac/* is class-gated @Roles('org_admin') (rbac.controller.ts:21) even though rbac.role.* / rbac.member.* permissions exist. Move to @Permissions('rbac.role.read') style so custom roles can manage RBAC? | Role-manager persona (02), matrix gating |
| OQ-R2 | isSystem defaults true on create (role.schema.ts:78-79) — silent lock-in of custom roles. Server default → false for non-seeded creates? | Create-role form, QA privilege-escalation tests |
| OQ-R3 | MemberStatus.INVITED/SUSPENDED defined but never produced (organization-member.schema.ts:7-11; service hardcodes ACTIVE rbac.service.ts:122). Invite/suspend flows (planned)? | Member lifecycle UX |
| OQ-R4 | JWT roles claim frozen at login; refresh copies old claim (auth.service.ts:192-196). Role change forces re-login. Server-side claim refresh? | 13 route rebuild, session UX |
| OQ-R5 | Duplicate member → 500 (Mongo E11000) instead of 409 DUPLICATE_RESOURCE. Map in filter? | Add-member error UX |
| OQ-R6 | organizations.service.ts:58 seeds defaults with org.slug as tenantId (vs auth.service.ts:93 using user.tenantId) — tenantId semantics inconsistency | Tenant-scope tests |
| OQ-R7 | Members list has no user profile join; avatar/name/email require user.read calls. Join endpoint or client-side merge (planned)? | Members list spec |
12. Glossary (module)
| Term | Meaning |
|---|---|
| Role | Named permission set, tenant-scoped; slug unique per tenant; system roles immutable |
| Permission | domain.action string (95 total); domain ≈ owning module |
| Membership | organization_members doc tying userId to roles within a tenant |
| Priority | Int; higher = listed first and "more powerful" by convention |
| Permission cache | Redis sl:{tenantId}:perm:{userId} TTL 300 s (rbac.service.ts:48,68) |
403 PERMISSION_DENIED | Envelope code for role/permission refusal (http-exception.filter.ts:30) |
| System role | isSystem: true; cannot be edited/deleted (rbac.service.ts:94-95,104-105) |
02 — User Personas (RBAC Module)
- 1. Priya — Organization Admin (primary persona)
- 2. Arjun — Role Manager (forward-looking, OQ-R1)
- 3. Samira — Member Viewer
- 4. Vikram — Platform Admin (sysadmin)
- 5. David — Guarded End-User (teacher)
- Persona → screen matrix
Five personas cover the RBAC surface. Derived from the seeded roles (
role.schema.ts:8-65), the endpoints (rbac.controller.ts), and the guard pipeline (rbac.guard.ts). The "Role manager" and "Member viewer" personas are forward-looking: the server currently gates/rbac/*behind@Roles('org_admin')(rbac.controller.ts:21), so their ability to act depends on OQ-R1.
1. Priya — Organization Admin (primary persona)
"I need to control who can touch what, and I don't have time for IT tickets."
- Slug/roles:
org_admin— all 95 permissions (role.schema.ts:17-24). - Context: Principal/admin of a 400-student school. Created the org at registration;
the system auto-added her as
org_adminmember (auth.service.ts:84-99). - Goals
- Seed the org quickly: teachers, accountant, librarian, transport manager each get a fitting role without exposing admin powers.
- Create a custom role ("Fee Counter Clerk") with exactly
fees.collect, student.read, receipts.read— no more, no less. - Add members, fix a wrong role, and remove a leaver in under a minute.
- Never accidentally lock herself out; never let a staff member see salary/finance data.
- Frustrations
- A custom role she creates becomes un-editable (system-lock bug,
role.schema.ts:78-79default; see OQ-R2) — the UI must forceisSystem:falseand warn. - Members list shows raw member docs without names (
rbac.service.ts:109-111) — the UI must mergeuser.readdata (OQ-R7). - She can't tell a leaver's permissions at a glance; wants an audit trail.
- A custom role she creates becomes un-editable (system-lock bug,
- Needs: permission matrix grouped by module with search; system-role lock badges;
member tiles with role chips; 403-prevention copy ("You are about to remove your own
org_admin— you will lose access to this screen").
2. Arjun — Role Manager (forward-looking, OQ-R1)
"I audit access quarterly. I want to see who holds what, and why."
- Roles (proposed): a custom role holding
rbac.role.read, rbac.role.update, rbac.member.read, rbac.member.update, user.read, audit.read— notorg_admin. - Context: Operations head trusted with access governance but deliberately not with org billing/settings.
- Goals: browse roles, inspect a role's permission set, adjust a role's perms, reassign
a member's roles, review the permission-audit view (
GET /audit-logs,audit.controller.ts:17). - Frustrations: today the server rejects him with 403 —
@Roles('org_admin')(rbac.controller.ts:21) ignores hisrbac.*permissions. Screen copy must explain "managed by Organization Admin" until OQ-R1 ships. - Needs: read-only-friendly layouts (view mode for the matrix), permission diff preview before saving, audit filter by actor/action.
3. Samira — Member Viewer
"I just need to know who's on the transport desk this week."
- Roles: custom role with
rbac.member.read, user.read(or a staff member). - Context: receptionist cross-checking who can issue books.
- Goals: open the members list, see names + role chips, search a member.
- Frustrations: zero write affordances must ever render for her; a stray "Add member" button that 403s on tap is a trust-breaker.
- Needs: view-only matrix/member list; no FAB; 403 states never reached via UI.
4. Vikram — Platform Admin (sysadmin)
"One product, thousands of tenants. I watch from above, not inside."
- Slug:
platform_admin—[]permissions (role.schema.ts:8-16), cross-tenant repository bypass (base.repository.ts:21). - Context: platform operator. Tenant-scoped RBAC is none of his daily business; his concern is that tenant A's admin can never touch tenant B.
- Goals: verify tenant isolation (QA 14), support escalation reads, never appear in tenant RBAC screens as a normal actor.
- Frustrations:
platform_adminis notorg_admin— the/rbac/*controller would 403 him (rbac.controller.ts:21); repository scoping lets him read across tenants (base.repository.ts:22). The client must treat platform_admin as a distinct shell persona with its own tenant picker — the tenant RBAC screens stay reachable only with an org_admin token in that tenant.
5. David — Guarded End-User (teacher)
"I hit a locked door once; I never want to hit it again."
- Slug:
teacher—student.read, attendance.mark, attendance.edit(role.schema.ts:25-32). - Context: marks attendance daily, sees student rosters. RBAC is invisible to him — until it isn't.
- Goals: never see admin screens; when a route changes mid-session (role edited at lunch), get kicked gracefully with a reason, not a white 403 screen.
- Frustrations: a 403 with no explanation; a hidden-then-broken menu item; being
logged out without explanation after an admin changed his role (JWT roles frozen at
login,
auth.service.ts:145-154; OQ-R4). - Needs: 403 screen with cause + contact copy; route tree rebuilt on permission change (05_Global_Information_Architecture.md §9); offline-tolerant lists.
Persona → screen matrix
| Screen | Priya (org_admin) | Arjun (role mgr, planned) | Samira (viewer) | Vikram (platform) | David (teacher) |
|---|---|---|---|---|---|
| Roles list | full | read/edit (planned) | — | — | — |
| Role detail | full | read | — | — | — |
| Role editor + matrix | full | edit (planned) | — | — | — |
| Members list | full | read/edit (planned) | read (if granted) | tenant-picker (planned) | — |
| Add member | full | edit (planned) | — | — | — |
| Permission audit | full (audit.read) | read | — | — | — |
| 403 screen | never | until OQ-R1 | never via UI | never via UI | when role revoked mid-session |
03 — User Journeys (RBAC Module)
- J1 — Create a custom role (Priya)
- J2 — Assign permissions via the matrix (Priya)
- J3 — Add a member (Priya)
- J4 — Custom role for Accountant/HR, then assign (Priya, J1+J3 combined)
- J5 — Guard-denied flow (David, teacher; also PLAN.md:17 1.5)
Five end-to-end journeys with Mermaid diagrams. Every step maps to a real endpoint (
rbac.controller.ts), a service rule (rbac.service.ts), or a guard behaviour (rbac.guard.ts). Personas per 02.
J1 — Create a custom role (Priya)
Entry: Roles list → FAB "Create role" → Role editor → Save.
sequenceDiagram
autonumber
actor A as Priya (org_admin)
participant C as Client
participant API as /api/v1/rbac
A->>C: Opens Roles list
C->>API: GET /rbac/permissions (catalog, 95 perms)
API-->>C: data:[ "organization.read", ... ] (permissions.constants.ts:1-97)
C->>API: GET /rbac/roles (existing roles, priority desc)
API-->>C: data:[{name,slug,isSystem,priority,permissions[]}]
A->>C: FAB → editor: name, slug, description, priority, matrix check-off
C-->>A: validations: slug pattern, ≥1 perm warning (not blocking), isSystem:false forced
A->>C: Save
C->>API: POST /rbac/roles {name,slug,description,isSystem:false,priority,permissions}
alt 409 duplicate slug (role.schema.ts:89)
API-->>C: {error.code:"DUPLICATE_RESOURCE"}
C-->>A: inline "Slug already exists" + focus slug
else 201/200
API-->>C: data: role doc
C-->>A: Snackbar "Role created" → navigate Role detail
end
Rules exercised: createRole conflict check (rbac.service.ts:85-87);
@Roles('org_admin') at class level (rbac.controller.ts:21); create is tenant-scoped
via createWithTenant (role.repository.ts:17-22).
J2 — Assign permissions via the matrix (Priya)
Entry: Role detail → Edit permissions → matrix → Save. Matrix = hero component
(06 §3).
flowchart TD
S[Role detail: 'Fee Counter Clerk'] --> E[Edit permissions]
E --> G[Matrix renders: 23 groups × chips, server catalog + current role perms]
G --> F{User actions}
F -->|Search 'pay'| SR[Filtered rows: payments.read, payments.process, payments.refund, payments.reconcile, receipts.read]
F -->|Tap group header 'Finance'| GA[Group select-all toggle]
F -->|Tap chip| TC[Toggle perm on/off, live count badge '6 of 95']
SR --> TC
GA --> TC
TC --> D{Save}
D -->|Cancel| E
D -->|Save| P[PATCH /rbac/roles/:id {permissions:[...]}]
P -->|200| R[Detail refreshes; perm chips updated; route tree of holders NOT rebuilt here]
P -->|403 PERMISSION_DENIED| E3[403 inline: "You can't edit roles"]
Rules exercised: update refused for isSystem (rbac.service.ts:94-95); permission
list sent wholesale (no per-cell endpoint — updateRole is full-doc $set,
rbac.service.ts:96); effective permissions change for holders ≤ 300 s after cache TTL
(rbac.service.ts:68) but JWT roles unchanged until re-login (auth.service.ts:145-154).
J3 — Add a member (Priya)
Entry: Members list → FAB "Add member" → pick user + roles → Add.
sequenceDiagram
autonumber
actor A as Priya
participant C as Client
participant API as /api/v1/rbac
participant U as /api/v1/users (read)
A->>C: Members list (GET /rbac/members, joinedAt desc)
C->>API: GET /rbac/members
API-->>C: data:[{userId,roles[],status,joinedAt,...}] (no profile join, rbac.service.ts:109-111)
C->>U: GET /api/v1/users (profile merge for display, OQ-R7)
A->>C: FAB → Add member sheet: search users, pick one, pick role chips
C->>API: POST /rbac/members {userId, roles:[...]} (add-member.dto.ts:4-12)
alt user already member (unique index org-member.schema.ts:48)
API-->>C: 500 (Mongo E11000, unmapped — OQ-R5)
C-->>A: conflict-styled banner "Already a member" (client-side pre-check)
else 201/200
API-->>C: data: member doc (status "active", joinedAt now — rbac.service.ts:122-123)
C-->>A: row appears at top; Snackbar "Member added"
end
J4 — Custom role for Accountant/HR, then assign (Priya, J1+J3 combined)
Entry: Roles → create "HR Officer" → Members → assign.
flowchart LR
A[Create role 'HR Officer'] -->|POST /rbac/roles| B[isSystem:false, priority 45]
B --> C[Select perms: user.read, user.create, user.update, staff.read, staff.create, staff.update, notification.read]
C --> D[Roles list shows HR Officer above Teacher? No - priority 45 < teacher 50]
D --> E[Members → Add member → pick Kamal → roles: hr_officer]
E --> F[POST /rbac/members {userId, roles:['hr_officer']}]
F --> G[Kamal re-login → JWT roles:['hr_officer'] auth.service.ts:145-154]
G --> H[Kamal opens Staff list → allowed via @Permissions('staff.read') when Phase-5 lands (planned)]
Note (derived): priority is display-ordering only (rbac.service.ts:76); it does not
influence authorization — authorization comes from JWT roles + permission resolution.
J5 — Guard-denied flow (David, teacher; also PLAN.md:17 1.5)
Entry: Teacher taps an admin-only action (or a stale route after his role was edited mid-session).
sequenceDiagram
autonumber
actor D as David (teacher)
participant C as Client
participant G as Guard chain
actor A as Priya (admin)
A->>G: PATCH /rbac/roles/:id removes 'teacher' from org (role edited at lunch)
D->>C: Opens Fees screen (stale route from login-time JWT)
C->>G: GET /api/v1/fees (JWT still claims roles:['teacher'] — auth.service.ts:145-154)
G-->>G: JwtAuthGuard OK → RbacGuard: no @Permissions on fees controller today (OQ-R1/12§4) → ALLOWED (rbac.guard.ts:29)
D->>C: Opens Webhooks screen
C->>G: POST /webhooks requires @Permissions('webhook.create') (webhooks.controller.ts:22)
G-->>G: getPermissionsForUser → Redis miss → DB: teacher role lacks webhook.create
G-->>C: 403 ForbiddenException 'Insufficient permission.' (rbac.guard.ts:48-49)
C-->>D: 403 screen: cause copy + "Ask your Organization Admin" + requestId; no stack
Covered by QA plan: PLAN.md:17 (1.5 RBAC admin hits teacher-only endpoint → 403),
PLAN.md:107-110 (10.2 cross-tenant JWT → 403), PLAN.md:146-148 (14 soft-delete).
04 — Information Architecture (RBAC Module)
- 1. Placement in the app shell
- 2. Information hierarchy
- 3. Screen-to-route table
- 4. Navigation rules
- 5. Content model (what each screen shows)
- 6. Related modules (cross-references)
Where RBAC lives in the app shell (00-shared/05). RBAC is an admin-governance surface: it sits under the Settings umbrella, gated by the
rbac.*permission family, and is invisible to non-admin personas.
1. Placement in the app shell
AppShell (role-aware, 05_Global_Information_Architecture.md §1)
└─ NavigationDrawer / Rail
├─ Home, Students, Attendance, Academics, Fees, … (module destinations)
└─ Settings (roles: admin — 05 §2 "Settings | /settings | admin")
├─ Organization profile
├─ Roles & Permissions ← RBAC package entry
│ ├─ Roles (list)
│ ├─ Create role
│ ├─ Role detail
│ └─ Role editor (permission matrix)
├─ Members (organization people + roles)
│ ├─ Add member
└─ Access audit (permission audit view; audit.read)
- Entry point: Settings → "Roles & Permissions" (routes
/settings/roles,/settings/members,/settings/access-audit). - Why Settings, not a top-level tab: the global nav (05 §2) reserves top-level
destinations for daily-use modules; RBAC is occasional governance. Matches
05 §2"Users & Roles |/users,/roles| admin".
2. Information hierarchy
Roles & Permissions
├─ Roles (the "what can be done" catalog) [GET /rbac/roles, GET /rbac/permissions]
│ ├─ System roles (7, locked badges)
│ └─ Custom roles (tenant)
│ └─ Role detail: identity + priority + permission summary
│ └─ Editor: full permission matrix (hero)
└─ Members (the "who has which roles") [GET /rbac/members]
├─ Member tile: user identity (merged via user.read, OQ-R7) + role chips
└─ Add member: user picker + role picker [POST /rbac/members]
└─ Access audit (the "who did what") [GET /audit-logs, audit.controller.ts:17]
└─ Filter: action / entityType / actorId
Parent-child rule: Role detail is reachable from Roles list only; member rows link
forward to nothing (no per-member detail screen — the API exposes none; PATCH /rbac/ members/:id is the only member write, rbac.controller.ts:69-73).
3. Screen-to-route table
| Route | Screen | Guard (client) | Server gate |
|---|---|---|---|
/settings/roles | Roles list | rbac.role.read | @Roles('org_admin') (rbac.controller.ts:21) |
/settings/roles/new | Role editor (create) | rbac.role.create | same |
/settings/roles/:id | Role detail | rbac.role.read | same |
/settings/roles/:id/edit | Role editor (edit) | rbac.role.update | same |
/settings/members | Members list | rbac.member.read | same |
/settings/members/add | Add member (sheet) | rbac.member.create | same |
/settings/access-audit | Permission audit | audit.read | @UseGuards(JwtAuthGuard) only (audit.controller.ts:9; perm gate (planned)) |
Client guard vs server gate mismatch (flagged): the client gates on
rbac.*permissions, the server on theorg_adminrole (OQ-R1). Until OQ-R1 lands, client routes must additionally require theorg_adminrole claim, else users withrbac.*perms would see screens that 403. Implemented asroleOrPermissionGuard(['org_admin'], ['rbac.role.read'])(see 13 §4, 15 §5).
4. Navigation rules
- Destinations hidden for anyone without the gate (05 §3 "Unauthorized destinations are hidden and unroutable").
RolesandMembersare sibling tabs under one "Access" section header;Access auditis a third tab (not nested deeper).- Breadcrumb (desktop ≥ 2 levels):
Settings / Roles / {role name}. - Keyboard:
Ctrl+Kglobal search may surface roles/members by name;Non Roles and Members lists starts the create flow (desktop).
5. Content model (what each screen shows)
| Screen | Primary entities | Secondary | Empty state |
|---|---|---|---|
| Roles list | Role (name, slug, description, isSystem, priority, permissions[] — role.schema.ts:69-85) | perm count badge, member count (proposed) | "No custom roles yet" |
| Role detail | Role identity + full perm set | member holders (proposed) | — |
| Role editor | 95-perm matrix | search, group select-all, count | — |
| Members list | Member (userId, roles[], status, joinedAt — organization-member.schema.ts:15-31) + merged user profile | role chips, status badge | "No members yet" |
| Add member | User picker + role picker | — | — |
| Access audit | AuditLog rows (action, entityType, actorId, ts) | filters | "No activity recorded" |
6. Related modules (cross-references)
- Users — identity for member tiles;
user.read…user.import(studylyon-blueprint/04-Modules/Users.md:71-79). - Auth — JWT carries
rolesclaim (jwt-payload.interface.ts:3); re-login required for claim refresh. - Organizations — tenant context;
seedDefaults(org.slug)quirk (organizations.service.ts:58). - Audit —
GET /audit-logsbacks the permission audit view (audit.controller.ts:17-34). - 05 Shared GIA §9 — client mirrors
permissions.constants.ts; route rebuild on role change.
05 — Screen Inventory (RBAC Module)
- S1 — Roles list
- S2 — Role detail
- S3 — Role editor (permission matrix — hero)
- S4 — Members list
- S5 — Add member
- S6 — Permission audit view
Six screens. Every element traces to an endpoint (
rbac.controller.ts) or schema field (role.schema.ts,organization-member.schema.ts). Detailed specs in 06; forms in 08; wire contracts in 12.
| # | Screen | Route | Server endpoint(s) | Personas |
|---|---|---|---|---|
| S1 | Roles list | /settings/roles | GET /rbac/roles | Priya, Arjun |
| S2 | Role detail | /settings/roles/:id | GET /rbac/roles (client-side lookup) | Priya, Arjun |
| S3 | Role editor (permission matrix) | /settings/roles/new, /settings/roles/:id/edit | GET /rbac/permissions, POST /rbac/roles, PATCH /rbac/roles/:id | Priya, Arjun |
| S4 | Members list | /settings/members | GET /rbac/members + user.read merge | Priya, Arjun, Samira |
| S5 | Add member | /settings/members/add (bottom sheet) | POST /rbac/members | Priya, Arjun |
| S6 | Permission audit view | /settings/access-audit | GET /audit-logs?action=&entityType=&actorId= (audit.controller.ts:17-34) | Priya, Arjun |
S1 — Roles list
- Purpose: browse the tenant's roles; distinguish locked system roles from custom.
- Elements:
headlineMedium"Roles & Permissions"; search field (client-filter on name/slug); list ofRoleRowsorted priority desc (rbac.service.ts:76);RoleRow= name + slug (mono) + description + system badge + permission-count badge; trailingAppMenu(Edit / Delete) on custom roles only; FAB "Create role". - States: loading
AppSkeleton(list); errorAppErrorState; empty "No custom roles yet — create your first role". - Server note: returns raw array, not paginated (
rbac.service.ts:75-77); nometain envelope (response-envelope.interceptor.ts:55-59).
S2 — Role detail
- Purpose: read a role; jump to edit; see what it grants.
- Elements: header (name, slug, description, priority, system badge); "N of 95
permissions" summary with permission group tiles (each group: label + chips of
granted perms — reuse
PermissionGroupSectionread-mode); CTA "Edit permissions" (disabled for system roles); member holders section(proposed)— server offers no members-by-role endpoint, so holders would require client-side join ofGET /rbac/ members+ roles ((proposed), OQ-R7). - States: loading; error 404 (role soft-deleted or wrong tenant → treat as not
found,
RESOURCE_NOT_FOUND); system-role banner "System roles cannot be edited".
S3 — Role editor (permission matrix — hero)
- Purpose: create/edit a role's permission set. The matrix is the module's hero component (07 §1, 06 §3, 15 §2).
- Elements: form header (name, slug, description, priority — 08 §1); live badge
"X of 95 selected";
AppSearchBarfiltering groups/perms; group sections (23 groups, §06) each with select-all toggle +PermissionChips; bottom bar: "Cancel" / "Save role" (loading state); duplicate-slug inline error on 409. - Rules (derived): system roles never reach the editor (server refuses with 400
"Cannot modify system roles."
rbac.service.ts:94-95); create always sendsisSystem:false(OQ-R2); save is one PATCH with the full array (rbac.service.ts:96$set— no per-cell endpoint). - States: loading (permissions + role fetch); error; dirty-state unsaved-changes dialog on back.
S4 — Members list
- Purpose: who belongs to the tenant and with which roles.
- Elements:
headlineMedium"Members"; search (name/email via merged profile);MemberTile=AppAvatar(user initials/photo) + displayName + email (user.readmerge, OQ-R7) + role chips + status badge (activeonly today,organization-member.schema.ts:7-11;invited/suspendedrender-only) + joined date; trailingAppMenu(Edit roles / Remove) — hidden for the viewer persona; FAB "Add member". - Server note: sorted
joinedAt desc(rbac.service.ts:110); array, not paginated; no profile join (see OQ-R7). - States: loading/error/empty ("No members yet — add your first member").
S5 — Add member
- Purpose: create a membership: pick an existing user + one or more role slugs.
- Elements: full-height
AppBottomSheet:AppSearchBaruser picker (fromGET /api/v1/users,users.controller.ts); selected user tile; role picker (FilterChips fromGET /rbac/roles, system + custom, ordered by priority); "Add member" CTA; inline error for duplicate member (client pre-check — server 500s on E11000, OQ-R5). - Fields/validation: 08 §2 —
userId@IsMongoId(add-member.dto.ts:6),roles@IsArray @IsString({each})(add-member.dto.ts:10-12). - Server note:
PATCH /rbac/members/:idreuses this DTO but ignoresuserId(rbac.controller.ts:71-72) — the edit-roles sheet can reuse the same role picker.
S6 — Permission audit view
- Purpose: answer "who changed what, when" for RBAC events (and all other actions).
- Elements: filter bar (action dropdown, entityType dropdown, actorId picker —
all query params of
audit.controller.ts:20-25); paginated log list (page/limit, default limit 50 server-sideaudit.controller.ts:20; envelope hasmeta— paginated shape per 07 §2); row = timestamp, action, entityType, actorId (mono), requestId. - States: loading/error/empty "No activity recorded"; pull-to-refresh; infinite scroll (paginated).
- Gate note: server guard is
JwtAuthGuardonly (audit.controller.ts:9) — theaudit.readpermission is not enforced yet (Phase-5(planned),docs/IMPLEMENTATION_PLAN.md:241); client gates the route onaudit.readregardless.
06 — Screen Specifications (RBAC Module)
- S1 — Roles List
/settings/roles - S2 — Role Detail
/settings/roles/:id - S3 — Role Editor (permission matrix)
/settings/roles/new·/settings/roles/:id/edit - S4 — Members List
/settings/members - S5 — Add Member
/settings/members/add(bottom sheet) - S6 — Permission Audit View
/settings/access-audit
Production-grade specs for all six screens (S1–S6, 05_Screen_Inventory.md). Tokens:
Sp= 00-shared/02 §3,Ty= §2,El= §5,R= §4,Mo= §6. Components reference 00-shared/03 and 07_Component_Library.md (this module). The permission matrix (§3, S3) is the module's hero component and gets the deepest spec.
S1 — Roles List /settings/roles
Chrome: AppBar (title "Roles & Permissions", back); headlineMedium; list area.
Layout (phone):
AppBar "Roles & Permissions"
SectionHeader "System roles" (titleSmall, onSurfaceVariant)
RoleRow × 7 (locked) [AppAvatar shield] Name · slug (mono) [System][N perms] ⋮
SectionHeader "Custom roles"
RoleRow × N name · description (1 line) [N perms] ⋮
FAB "Create role" (extended)
Elements & behaviour:
RoleRow: leadingAppAvatar(initials of role name,primaryContainer); title = rolename(role.schema.ts:69-70); subtitle = slug inmono+ description (role.schema.ts:75-76) ellipsized; trailing =AppBadge"System" (locked,outline) or perm countAppBadge "{n} perms";AppMenu: custom → Edit / Delete; system → disabled (tooltip "System role").- Sections split by
isSystem(role.schema.ts:78-79); ordering priority desc (rbac.service.ts:76) within each section — Platform Admin (1000) then Org Admin (100) then Teacher (50)… in system; custom roles sorted by their priority. - Search:
AppSearchBarclient-filters name/slug (list is small; server has noq). - Delete:
AppDialogconfirm ("Delete role '{name}'? N members hold this role — they will lose its permissions within 5 minutes.") →DELETE /rbac/roles/:id(soft delete,rbac.service.ts:106) → optimistic remove + snackbar. - FAB: "Create role" →
/settings/roles/new.
States: loading AppSkeleton(list); error AppErrorState (UNAUTHENTICATED →
refresh flow; PERMISSION_DENIED → 403 screen); empty → only system section renders,
empty-state card "No custom roles yet".
A11y: system badge read as "System role — locked"; row semantics button; menu
actions labelled ("Edit role", "Delete role").
Tablet/desktop (≥ 840 dp): master-detail — list left (width 360), detail pane right
(go_router split). Hover states; keyboard ↑↓ moves selection, Enter opens detail.
S2 — Role Detail /settings/roles/:id
Chrome: AppBar (role name, back); scroll body; SafeArea bottom action bar.
Layout:
AppBar "{name}"
[Banner: system role → "System roles can't be edited." (info)] (only if isSystem)
Identity card (AppCard e-1):
name titleLarge
slug mono
description bodyMedium onSurfaceVariant
priority InfoRow "Priority" value
system badge AppBadge System / Custom
"Permissions · {n} of 95" SectionHeader
PermissionGroupSection × N (read-only): group label + granted chips only
[proposed] "Members with this role" — client-joined list (OQ-R7)
Bottom bar: [Edit permissions] FilledButton (hidden if isSystem)
Behaviour:
- Data = role doc from the list fetch (client holds the loaded list; no per-id GET
endpoint exists —
rbac.controller.tsexposes onlyGET /rbac/roles). Deep-linked:id→ load full list, pick by id; missing →AppErrorState404 semantics. - Edit →
/settings/roles/:id/edit(S3). Save → detail refreshes from the returned doc (rbac.service.ts:96returns updated doc). - Members-with-this-role:
(proposed)— computed client-side by intersectingGET /rbac/memberswith the role slug; no server endpoint (OQ-R7).
A11y: group headers Semantics(headers:); chips readable in one pass; contrast for
locked badge ≥ 3:1.
S3 — Role Editor (permission matrix) /settings/roles/new · /settings/roles/:id/edit
The hero. One screen for create and edit; create = same form with empty matrix.
Chrome: AppBar ("Create role" / "Edit role"), subtitle slug; body = form fields
(Section A, 08 §1) + matrix; bottom sticky bar.
Layout (phone portrait):
AppBar "Create role"
name AppTextField (autofocus on create)
slug AppTextField (mono, helper "Used in API & membership")
description AppTextField (multiline, 2)
priority AppTextField (number; default 0, role.schema.ts:82)
───────────── divider
Row: "Permissions" titleMedium • AppBadge "{sel} of 95" (live)
AppSearchBar hint "Search permissions…" (filters perms + groups)
[Matrix] PermissionGroupSection × 26 (scrollable, grouped)
─────────────
Bottom bar: [Cancel] OutlinedButton | [Save role] FilledButton (loading)
Matrix component spec (hero):
- Source of truth:
GET /rbac/permissions(rbac.service.ts:79-81returnsALL_PERMISSIONS,permissions.constants.ts:1-97) fetched on editor open; role's current perms = itspermissions[](role.schema.ts:84-85). - Grouping (forward-looking client matrix): 26 groups derived from the
domainprefix of each permission. Full table (order = source order within group):
| Group | Permissions |
|---|---|
| Organization | organization.read, organization.update, organization.delete, organization.settings.update |
| Users | user.read, user.create, user.update, user.delete, user.import |
| Roles & Access | rbac.role.read, rbac.role.create, rbac.role.update, rbac.role.delete, rbac.member.read, rbac.member.create, rbac.member.update, rbac.member.delete |
| Staff | staff.read, staff.create, staff.update, staff.delete, department.manage, designation.manage |
| Students | student.read, student.create, student.update, student.delete |
| Attendance | attendance.mark, attendance.edit |
| Fees | fees.collect |
| Notifications | notification.read, notification.update |
| CRM | crm.read, crm.lead.manage, crm.campaign.manage |
| Dashboard | dashboard.read, dashboard.widget.manage |
| Reports | report.generate, report.read |
| Biometric | biometric.log.create, biometric.log.read, biometric.device.manage |
| Timetable | timetable.read, timetable.create |
| Houses | houses.read, houses.create, houses.update, houses.delete |
| Rooms | rooms.read, rooms.create, rooms.update, rooms.delete |
| Audit | audit.read |
| Library | books.read, books.create, books.update, books.delete, books.issue, books.return |
| Fines | fines.pay |
| Transport | transport.vehicle.read, transport.vehicle.create, transport.vehicle.update, transport.vehicle.delete, transport.route.read, transport.route.create, transport.route.update, transport.route.delete, transport.driver.read, transport.driver.create, transport.driver.update, transport.driver.delete, transport.assign |
| Settings | settings.read, settings.update, settings.delete |
| Feature Flags | feature-flags.read, feature-flags.update, feature-flags.delete |
| Payments | payments.read, payments.process, payments.refund, payments.reconcile, receipts.read |
| Files | file.read, file.upload, file.delete |
| Webhooks | webhook.create, webhook.read, webhook.update, webhook.delete |
| Search | search |
| Scheduler | scheduler.read, scheduler.create, scheduler.delete |
PermissionGroupSection: group header = label +Checkbox(tri-state: none/some/all)- "{k}/{n}"; body =
WrapofPermissionChips (FilterChip-style, label = full perm string inmono-ish small type, tooltip = humanized name(proposed)).
- "{k}/{n}"; body =
- Toggle semantics: chip tap toggles; group checkbox toggles all in group; search filters to matched group sections (sections with 0 matches collapse to "no results").
- Live count badge updates on every toggle; no server call until Save (one PATCH,
rbac.service.ts:96— no per-cell endpoint exists). - Forward-looking notes: the matrix renders only the 95 permissions returned by the
server; coaching perms (
batch.manageetc.) and roles (batch_coordinator…,docs/IMPLEMENTATION_PLAN.md:713-761) are(planned)and must not be rendered until the server ships them.
Behaviour & rules:
- Create: sends
{name, slug, description?, priority?, isSystem:false, permissions}—isSystem:falseis forced (OQ-R2; server defaulttruelocks the role,role.schema.ts:78-79). - Save →
POST /rbac/rolesorPATCH /rbac/roles/:id; 409 duplicate slug → inline under slug field, focus it; 400 (system role — should be unreachable) → banner. - Dirty state: back navigation with unsaved changes →
AppDialog"Discard changes?". - Empty selection on create → warning banner "Role with no permissions — members will
only see the base shell" (allowed server-side; e.g.
studentrole is[]). - Priority field hint: "Higher number lists the role first" (
rbac.service.ts:76).
States: loading (catalog + role) skeleton; saving → CTA spinner, matrix disabled, anti-double-submit; success → snackbar + pop to detail.
A11y: every chip = toggle with Semantics(checked:) + label + group context;
search results announced (liveRegion "3 groups match"); checkbox tri-state announced
("none / some / all selected").
Tablet/desktop: matrix in a 2-column grid (groups wrap); sticky group header on
scroll; Ctrl+F focuses search; tab order = fields → matrix rows.
S4 — Members List /settings/members
Chrome: AppBar "Members"; headlineMedium; list; FAB.
Layout:
AppBar "Members"
AppSearchBar (name / email filter — merged profile, OQ-R7)
MemberTile × N:
AppAvatar 48 (photo or initials) displayName (titleMedium)
email · joined {date} (bodySmall)
[role chips ×k] (FilterChip, read-only, tap = no-op
w/ tooltip "Managed in role editor")
[status badge: active/suspended/invited] (render-only)
⋮ (Edit roles / Remove — admin only)
FAB "Add member" (extended)
Behaviour:
- Data:
GET /rbac/members(rbac.service.ts:109-111, joinedAt desc) + profile merge viauser.read(batchGET /api/v1/users, OQ-R7); merge failures renderuserIdinmonofallback. - Roles per member =
roles[](organization-member.schema.ts:22) resolved to role docs for labels/priority; unknown slugs (role deleted) render as plain slug chips. - Status badge from
status(organization-member.schema.ts:27); today alwaysactive(OQ-R3);suspended/invitedrender-only until server produces them. - Row menu (admin): "Edit roles" → S5 sheet in edit mode (
PATCH /rbac/members/:id, DTO requiresuserIdbut service ignores it — client may send the sameuserIdfrom the row,rbac.controller.ts:71-72); "Remove member" →AppDialogdestructive confirm ("Remove {name}? Their access ends within 5 minutes.") →DELETE /rbac/members/:id(rbac.service.ts:138-140soft delete; silent 200 even if already removed) → optimistic removal. - Self-guard: Priya removing her own
org_adminmembership → extra typed confirm (type the role slug pattern,05 §5) + warning "You will lose access to this screen immediately on next refresh."
States: skeleton / error / empty ("No members yet — add your first member"); pull to refresh.
S5 — Add Member /settings/members/add (bottom sheet)
Full-height AppBottomSheet (Sp-16 padding, r-xl top):
DragHandle "Add member" titleLarge
Step 1 — Person:
AppSearchBar "Search users…" (debounced, user.read; selectable results = AppListTile:
avatar + displayName + email)
[selected user tile] AppCard: avatar, name, email, [change]
Step 2 — Roles:
SectionHeader "Roles"
FilterChip × roles (GET /rbac/roles, priority order; system + custom; multi-select)
helper: "Roles control permissions. Members can hold several."
[Add member] FilledButton fullWidth (disabled until user + ≥1 role)
Rules (derived):
- Body:
{userId, roles}—@IsMongoId(add-member.dto.ts:6-7),@IsArray@IsString({each})(add-member.dto.ts:9-12). - Duplicate pre-check client-side against loaded member list (unique index
organization-member.schema.ts:48; server would 500 — OQ-R5) → inline error "Already a member". - Success → 200/201 doc (
status:"active",joinedAtnow —rbac.service.ts:122-123); sheet closes, row appears top of S4, snackbar "Member added". - Edit mode (from S4 menu): same sheet, title "Edit roles", user tile read-only, chips preselect current roles, CTA "Save roles".
A11y: sheet traps focus; role chips Semantics(selected:); user results
liveRegion count.
S6 — Permission Audit View /settings/access-audit
Chrome: AppBar "Access audit"; filter bar; paginated list.
Layout:
AppBar "Access audit"
Filter row (Wrap of 3 AppDropdown + clear):
action (free-text suggestions: role.create, member.delete, user.login…)
entityType (roles, organization_members, users…)
actorId (person picker, user.read)
[AppBanner info: "Shows the last 90 days. RBAC writes appear here." (proposed copy)]
AuditRow × 20:
action (labelLarge, monospace accent for RBAC actions)
entityType · actorId (mono)
timestamp (bodySmall, relative)
Infinite scroll + "end of list" footer (meta.totalItems — paginated, 07 §2)
Behaviour:
- Query
GET /audit-logs?page=&limit=&action=&entityType=&actorId=(audit.controller.ts:17-34); default limit 50 (audit.controller.ts:20); pagination envelope withmeta({page,limit,totalItems,totalPages,hasNext,hasPrevious},07 §2). - RBAC focus: presets in the action dropdown (
(proposed)convenience): "Role created", "Role updated", "Role deleted", "Member added", "Member updated", "Member removed" — mapped to whateveractionstrings the audit module writes (audit-writequeue,PLAN.md:189-191; exact strings(planned), OQ-R8). - Row tap → no detail screen (no server detail endpoint) — copies requestId
(
(proposed)). - 401/403 handling standard; server currently gates with
JwtAuthGuardonly (audit.controller.ts:9) — client still requiresaudit.read(mismatch flagged, Phase-5(planned)).
Tablet/desktop: filters in the AppBar row; table view (columns: time, action, entity, actor, requestId) instead of list tiles.
07 — Component Library (RBAC Module)
- 1.
PermissionMatrix(hero component) - 2.
PermissionGroupSection - 3.
PermissionChip - 4.
RoleRow - 5.
RoleBadge/SystemRoleBadge - 6.
MemberTile - 7.
MemberStatusBadge - 8.
RolePickerChips - 9.
UserSearchPicker - 10.
AuditRow/AuditFilterBar - 11.
PermissionSummary(read-mode group list) - 12. Shared-component reuse map
Module-specific components. All base widgets (cards, lists, chips, dialogs, sheets, menus, skeletons, states) come from 00-shared/03; this file adds the RBAC layer. Every component is derived from schema fields (
role.schema.ts,organization-member.schema.ts) and the guard model (rbac.guard.ts).
1. PermissionMatrix (hero component)
- Purpose: render + edit the full permission catalog for one role (S3).
- Props:
permissions(catalog fromGET /rbac/permissions),selected(Set of granted perm strings),onChanged(Set<String>),searchQuery,readOnly,compact. - Structure: 26
PermissionGroupSections (grouping table in 06 §3), each with tri-stateCheckbox(none/some/all) +WrapofPermissionChips; optionalAppSearchBar(owned by parent); live countAppBadge "{sel} of 95". - Behaviour: chip tap → toggle; group checkbox → toggle all; search → filter
sections (0-match sections collapse);
readOnly→ chips render as plain labels withSemantics(label: "granted"); no server calls until save (single PATCH,rbac.service.ts:96). - Flutter: stateful widget owning
Set<String> selected+Map<String,bool> groupState; sections =ListView.builderchildren (lazy); chips =FilterChipwithvisualDensity: compact;RepaintBoundaryper section (see 15 §2). - Golden: phone/tablet/desktop × light/dark × readOnly/editable.
2. PermissionGroupSection
- Header row: group label (
titleSmall), tri-stateCheckbox,"{k}/{n}"counter (labelSmall); bodyWrap(spacing:8, runSpacing:8)of chips. Semantics(headers:)on header; tri-state announced.
3. PermissionChip
FilterChip-like; label = full permission string (bodySmall,monofeel), selected =primaryContainer/onPrimaryContainer; unselected =surfaceVariant.- Tooltip: humanized name
(proposed), e.g.payments.reconcile→ "Reconcile payments" (i18n keyperm.payments.reconcile); falls back to raw string. - A11y:
Semantics(checked: selected, label: '<perm>').
4. RoleRow
AppListTile-based list row:AppAvatar(initials,primaryContainer), title = role name, subtitle = slug (mono) + description ellipsized, trailing =AppBadge"System"/"{n} perms" +AppMenu(Edit/Delete for custom; disabled for system).- Sort:
priority desc(rbac.service.ts:76) — provided pre-sorted by parent.
5. RoleBadge / SystemRoleBadge
AppBadgevariants:System(outlined, tooltip "Locked — can't be edited"),Custom, and permission-count badge"{n} perms". System badge always onisSystem:trueroles (role.schema.ts:78-79).
6. MemberTile
AppListTile:AppAvatar48 (photo/initials from merged user profile, OQ-R7), title displayName (fallbackuserIdmono), subtitle = email + "Joined {date}" (organization-member.schema.ts:30-31), trailing = role chips +AppMenu(Edit roles / Remove — admin only).- Role chips = read-only
AppChips(tap no-op, tooltip "Managed in role editor"); unknown slug renders raw.
7. MemberStatusBadge
AppBadgefromMemberStatus(organization-member.schema.ts:7-11):active→ success (secondary),invited→tertiary(info),suspended→error. Render-only: server produces onlyactivetoday (OQ-R3).
8. RolePickerChips
- Multi-select
FilterChipwrap fromGET /rbac/roles(priority order); used in S5 (add/edit member). Props:roles,selected,onChanged. System roles get a tiny lock glyph (still selectable — e.g. assigningteacher).
9. UserSearchPicker
AppSearchBar-driven result list (user.read),AppListTilerows (avatar, name, email); single-select with check; props:onPicked(User),preselected?.
10. AuditRow / AuditFilterBar
AuditRow: action (labelLarge), entityType · actorId (monobodySmall), timestamp (relative,bodySmall); tap → copies requestId(proposed).AuditFilterBar: threeAppDropdowns (action, entityType, actorId) + clear button; map toaudit.controller.ts:20-25query params; RBAC presets(proposed)(OQ-R8).
11. PermissionSummary (read-mode group list)
- Compact read-only block for S2: group label + granted
PermissionChips (or "None in this group"). ReusesPermissionGroupSectioninreadOnly.
12. Shared-component reuse map
| Need | Shared component (00-shared/03) |
|---|---|
| Loading | AppSkeleton (list/card), AppProgress |
| Empty / error / offline | AppEmptyState, AppErrorState, AppOfflineBanner |
| Feedback | AppSnackbar, AppBanner (system-role info, 409 conflict) |
| Inputs | AppTextField, AppDropdown, AppSearchBar, AppCheckbox |
| Display | AppCard, AppAvatar, AppBadge, AppSectionHeader, AppInfoRow |
| Actions | AppFAB (Roles/Members lists), AppButton, AppDialog (delete/self-removal confirm), AppBottomSheet (S5), AppMenu (row actions) |
08 — Form Specifications (RBAC Module)
- 1. Create / Edit Role form (S3 top section)
- 2. Add Member form (S5 sheet)
- 3. Audit filter form (S6, non-mandatory)
- 4. Validation summary table
Field-by-field specs for the three RBAC forms. Validation mirrors the server DTOs (
create-role.dto.ts,add-member.dto.ts) plus client-side guards the server does not enforce (flagged per field). Wire contract per 00-shared/07.
1. Create / Edit Role form (S3 top section)
Request shape: CreateRoleDto (create-role.dto.ts:10-38) — used for both POST /rbac/roles and PATCH /rbac/roles/:id (rbac.controller.ts:39-49).
| # | Field | Control | Server validation (source) | Client rules |
|---|---|---|---|---|
| 1 | name | AppTextField, autofocus (create) | @IsString required (create-role.dto.ts:12-13) | required; max 60 chars; trim; unique-ish by display (slug is the real unique) |
| 2 | slug | AppTextField mono | @IsString required (create-role.dto.ts:15-17) | required; pattern ^[a-z0-9_]{2,40}$; generated suggestion from name (snake_case) editable; on 409 DUPLICATE_RESOURCE (rbac.service.ts:86-87) → inline error + focus; helper "Used in API & membership — can't be changed later" (update actually allows $set of slug, rbac.service.ts:96 — but changing breaks member role arrays; treat as immutable in UI) |
| 3 | description | AppTextField multiline | @IsOptional @IsString (create-role.dto.ts:19-22) | optional; ≤ 200 chars; helper text; 2 lines |
| 4 | priority | AppTextField number | @IsOptional @IsNumber (create-role.dto.ts:29-32); schema default 0 (role.schema.ts:82) | integer 0–1000; default 0; helper "Higher = listed first (Roles list sorts priority desc, rbac.service.ts:76). Display only — doesn't grant more power." |
| 5 | isSystem | hidden (never rendered) | @IsOptional @IsBoolean (create-role.dto.ts:24-27); schema default true (role.schema.ts:78-79) | always sends false (OQ-R2) — otherwise the new role locks itself |
| 6 | permissions | PermissionMatrix (07 §1) | @IsOptional @IsArray (create-role.dto.ts:34-37) — no whitelist check | client-side only: every string must be in the catalog from GET /rbac/permissions (rbac.service.ts:79-81); empty set allowed (warning banner, e.g. student role) |
Submit flow (both modes):
- Edit:
PATCH /rbac/roles/:idwith the full doc{name, slug?, description?, priority?, isSystem?, permissions}— server$sets wholesale (rbac.service.ts:96); system roles → 400 "Cannot modify system roles." (rbac.service.ts:94-95) — unreachable via UI (editor hidden for system roles). - Create:
POST /rbac/roles; duplicate slug → 409 (rbac.service.ts:87). - Errors: field-level for slug/name (400
VALIDATION_ERRORwithdetails[].field), banner for 403; anti-double-submit; success → snackbar → pop to detail.
2. Add Member form (S5 sheet)
Request shape: AddMemberDto (add-member.dto.ts:4-12) for POST /rbac/members;
same DTO reused by PATCH /rbac/members/:id (edit mode — rbac.controller.ts:71-72).
| # | Field | Control | Server validation (source) | Client rules |
|---|---|---|---|---|
| 1 | userId | UserSearchPicker (07 §9), read-only tile once picked | @IsMongoId required (add-member.dto.ts:6-7) | required; must exist in user.read results; pre-check against loaded members → "Already a member" inline (server 500s on E11000, OQ-R5) |
| 2 | roles | RolePickerChips (07 §8) multi-select | @IsArray @IsString({each: true}) required (add-member.dto.ts:9-12) | ≥ 1 required for submit; ≤ 10 (UI cap, (proposed)); slugs must resolve in GET /rbac/roles (warn + strip unknown); system roles selectable (assign teacher is normal) |
Submit flow:
- Create:
POST /rbac/members→ 200/201 doc withstatus:"active",joinedAtserver-set (rbac.service.ts:122-123); sheet closes; row appears top (list sorts joinedAt desc,rbac.service.ts:110). - Edit mode:
PATCH /rbac/members/:idwith{userId: <row's userId>, roles:[…]}(server ignoresuserId—rbac.controller.ts:72; client echoes the row's value for DTO compliance); success → tile chips refresh; 404 → "Member removed elsewhere" snackbar + remove row. - Remove:
AppDialogdestructive confirm →DELETE /rbac/members/:id(rbac.service.ts:138-140); silent 200 even if already gone (no 404) → optimistic removal.
3. Audit filter form (S6, non-mandatory)
| Field | Control | Query param (audit.controller.ts:20-25) | Notes |
|---|---|---|---|
| action | AppDropdown (free-text) | action | suggestions + RBAC presets (proposed) (OQ-R8) |
| entityType | AppDropdown (free-text) | entityType | e.g. roles, organization_members |
| actorId | UserSearchPicker | actorId | via user.read |
| page/limit | implicit | page, limit (default 50 server, audit.controller.ts:20) | infinite scroll appends pages |
Filters combine (all optional; server builds filter object audit.controller.ts:26-29);
"Clear" resets all.
4. Validation summary table
| Rule | Where enforced |
|---|---|
| slug unique per tenant | server 409 + unique index (rbac.service.ts:86-87, role.schema.ts:89) + client inline |
| system role immutable | server 400 (rbac.service.ts:94-95,104-105) + UI hides editor/delete |
| isSystem forced false on create | client only (OQ-R2) |
| permission names whitelisted | client only (server @IsArray, create-role.dto.ts:36) |
| member duplicate | client pre-check (server 500, OQ-R5) |
| member roles non-empty | client only (server @IsArray allows [], add-member.dto.ts:10) |
userId MongoId | server 400 (add-member.dto.ts:6) |
09 — User Behaviour (RBAC Module)
- 1. Core state machine (all lists)
- 2. Data freshness rules
- 3. Permission propagation (the critical behaviour)
- 4. Destructive actions
- 5. Offline behaviour
- 6. Concurrency / multi-admin
- 7. Empty / zero states
- 8. Rate limiting
- 9. Session expiry mid-edit
- 10. Behaviour by persona (summary)
Behavioural rules for the RBAC surface: state machines, refresh, caching, offline, destructive paths, and the permission-change propagation story. Server rules quoted from
rbac.service.ts/rbac.guard.ts; client conventions from 00-shared/06.
1. Core state machine (all lists)
Initial → Loading → Success(Empty) ─┐
│ ├→ Content
└→ Error(ApiException) ←┘
Error → Retry → Loading
Success → PullToRefresh → Loading(background, keep content)
Per 00-shared/06 §3.1; screens never render blank.
2. Data freshness rules
| List | Server cache | Client cache (proposed, 00-shared/06 §3.3) | Refresh triggers |
|---|---|---|---|
Roles (GET /rbac/roles) | none (rbac.service.ts:75-77 raw) | 5 min, stale-while-revalidate | pull-to-refresh; return from editor; after delete |
Permissions (GET /rbac/permissions) | none (static constant) | 24 h — catalog only changes with deploys (permissions.constants.ts:1-97) | app foreground if > 24 h |
Members (GET /rbac/members) | none | 5 min | pull-to-refresh; after add/edit/remove |
| Users (profile merge) | none | 24 h reference cache (00-shared/06 §3.3) | — |
Audit (GET /audit-logs) | none (paginated) | none beyond current page | infinite scroll; pull-to-refresh |
3. Permission propagation (the critical behaviour)
Derived from source — three different latencies:
| Change | Takes effect | Source |
|---|---|---|
| Role permission edit | ≤ 300 s (Redis cache TTL sl:{tenantId}:perm:{userId} EX 300) | rbac.service.ts:48,68 |
| Member roles change | next login (JWT roles claim minted at login, auth.service.ts:145-154; refresh reuses old claim auth.service.ts:192-196) | OQ-R4 |
| Member removed | immediate for new requests IF the permission check runs (guard resolves via member doc, rbac.service.ts:56-65); JWT roles claim still valid until re-login — role-checked endpoints (@Roles, e.g. rbac.controller.ts:21) stay open until token expiry | rbac.guard.ts:39-41 |
UI behaviour:
- After saving a role's permissions, the editor shows: "Permissions apply to holders within 5 minutes; role claims refresh at next login." (info banner).
- Route-tree rebuild (
05 §9) happens on login/permission refresh — not on RBAC writes by a different admin (no server push event exists; OQ-R9). - Removing/deleting while own token holds the affected role → post-save hint + offer "Refresh session" (re-login) — else the UI may show screens the server now denies.
4. Destructive actions
| Action | Confirm | Server behaviour (source) |
|---|---|---|
| Delete role | AppDialog — lists holder count (proposed); copy: "N member(s) hold this role. They lose its permissions." | soft delete (rbac.service.ts:106); system → 400 (blocked in UI) |
| Remove member | AppDialog destructive | soft delete, silent 200 even if absent (rbac.service.ts:138-140) |
Remove own org_admin | typed confirm (type org_admin slug, 05 §5) + "You will lose access" warning | same as above; JWT keeps access until expiry (see §3) |
No optimistic deletes with undo: deletes are irreversible-enough (soft-delete but no
restore endpoint — rbac.service.ts:106,139) → server-confirm then remove row
(00-shared/03 F rule: irreversible ops are never optimistic).
5. Offline behaviour
- Reads: last-good cache renders +
AppOfflineBanner; all write CTAs disabled with tooltip "Connect to retry" (no offline write queues for RBAC —00-shared/12C3). - Stale matrix: if editor opened offline, matrix renders from 24 h permission cache and 5 min role cache; Save blocked; on reconnect → refetch + compare (banner "Permission catalog updated" if diff).
6. Concurrency / multi-admin
- Two admins editing the same role: last-writer-wins (
$setwhole doc,rbac.service.ts:96;versionfield exists inbase.schema.ts:30-31but no optimistic lock is enforced on role update — OQ-R10). Mitigation: client shows "edited elsewhere" on conflict only if the fetched doc differs at save time — refresh detail before save when the screen has been backgrounded > 5 min. - Duplicate member add across two admins: server 500 (E11000, OQ-R5) — client shows conflict copy + refreshes list.
7. Empty / zero states
- Roles: system section always renders (7 seeded roles,
role.schema.ts:8-65); custom section empty-state. - Members: "No members yet — add your first member." (org_admin always exists in
practice — created at registration
auth.service.ts:88-91). - Matrix search: "No permissions match 'xyz'".
- Audit: "No activity recorded" (empty
meta.totalItems === 0).
8. Rate limiting
/rbac/*uses the defaultapitier, 100/min (rate-limit.guard.ts:36-37); audit default too. Enforcement only in production (rate-limit.guard.ts:30).- 429 →
AppBannercountdown + backoff, no auto-retry (00-shared/06 §5).
9. Session expiry mid-edit
- 401 during Save → silent refresh → retry once → failure: preserve form state in
memory, route to login with "re-login to continue" snackbar; matrix selections survive
re-login (state held by cubit,
13 §2).
10. Behaviour by persona (summary)
| Persona | What they see | What they can do |
|---|---|---|
| org_admin | full | everything |
| role manager (planned) | roles/members/audit | per OQ-R1 grants; edit matrix |
| member viewer | members list read-only | search, view |
| platform_admin | tenant picker shell (planned) | cross-tenant reads via repo bypass (base.repository.ts:21) — never inside tenant RBAC UI without org_admin token |
| teacher etc. | nothing | 403 screen if route reached |
10 — Interaction Specification (RBAC Module)
- 1. Permission matrix interactions
- 2. Screen transitions
- 3. Feedback & haptics
- 4. Frustration controls
- 5. Desktop keyboard map
- 6. Swipe / long-press (phone)
- 7. Modal hierarchy (RBAC)
- 8. Motion budget notes
Module-specific interaction & motion, layered on 00-shared/08. Focus: the permission matrix (dense, high-interaction) and destructive governance flows. Tokens:
Mo= 00-shared/02 §6.
1. Permission matrix interactions
| Gesture / input | Behaviour |
|---|---|
| Tap chip | Toggle perm; selected = primaryContainer, unselected = surfaceVariant; count badge AnimatedSwitcher update Mo.m-instant; HapticFeedback.selectionClick() on mobile |
| Tap group checkbox | Toggle all perms in group; tri-state renders none/some/all |
| Tap group header label | Collapse/expand section (AnimatedSize, Mo.m-base, no layout pop) |
| Search typing | Debounce 300 ms (00-shared/03 B); filters chips + collapses 0-match sections; result count announced (liveRegion) |
| Long-press chip | Tooltip with humanized name (proposed) + raw string (07 §3) |
| Hover (desktop) | Chip lifts Mo.m-fast; cursor pointer; tooltip after 500 ms |
Ctrl+F / / (desktop) | Focus matrix search |
Tab | Field order: name → slug → description → priority → matrix groups (chips are focusable toggles, not tab stops — arrow keys move within a FocusTraversalGroup) |
| Space / Enter on focused chip | Toggle (native M3 checkbox/chip behaviour) |
| Drag-select | (proposed) — drag across chips to mass-toggle (batched on release; no per-cell writes — server has none) |
Density: chips visualDensity: compact, Sp-8 gaps, min touch target 44 dp on
phone (dense inline exemption, 02 §9), 48 dp on desktop for mouse precision.
2. Screen transitions
| Transition | Token |
|---|---|
| Roles list → detail / editor | forward slide Mo.m-base; hero: role avatar flies to detail header Mo.m-slow |
| List → sheet (S5) | showModalBottomSheet default Mo.m-base, r-xl top corners |
| Section collapse in matrix | AnimatedSize Mo.m-base easeInOutCubic |
| Count badge change | scale pop Mo.m-entrance (value-only, no shimmer) |
| Skeleton → content | cross-fade Mo.m-slow |
| Reduced motion | all → Mo.m-instant fades (system setting; no stagger, no parallax) |
3. Feedback & haptics
| Event | Feedback |
|---|---|
| Toggle perm / select-all | selectionClick haptic; chip ripple Mo.m-fast |
| Save success | snackbar "Role created/updated" (< 3 s) + lightImpact |
| Save error | snackbar error (≥ 4 s) + mediumImpact; 409 inline under slug |
| Delete confirm | heavyImpact on destructive confirm tap |
| Remove member | optimistic row removal after 200 + snackbar; lightImpact |
| Pull-to-refresh complete | lightImpact |
4. Frustration controls
- Save button: loading spinner replaces label; disabled while pending (anti-double-submit, 00-shared/08 §6).
- Every tap responds ≤ 100 ms (press state visible — chips darken 2% on press).
- Matrix save is one request; no per-cell pending states.
- Back with dirty form →
AppDialog"Discard changes?" (ESC / barrier = keep editing).
5. Desktop keyboard map
| Key | Action |
|---|---|
Ctrl+F, / | matrix search focus |
N | Roles/Members list → create flow |
↑/↓ | list selection (master-detail), matrix group navigation |
Enter | open selected row / submit form |
Esc | close dialog/sheet/menu/search; cancel selection |
Ctrl+Enter | save from matrix search field |
6. Swipe / long-press (phone)
- Row swipe: not used for roles/members (destructive; 00-shared/08 §1 keeps swipe for safe ops only).
- Long-press
RoleRow/MemberTile→AppMenu(same as trailing ⋮). - Long-press chip (matrix) → tooltip only.
7. Modal hierarchy (RBAC)
- Snackbar (save/remove feedback)
- Bottom sheet (S5 add member)
- Dialog (delete confirm, typed self-removal confirm, discard-changes)
- Full page (editor S3 — >3 fields rule, 05 §5)
8. Motion budget notes
- Matrix scroll: no parallax, no sticky-header animation beyond
Mo.m-instantelevation shift (El-2sticky group header on desktop). - Audit list infinite-scroll: bottom spinner fade
Mo.m-instant.
11 — Design System Mapping (RBAC Module)
- 1. Component → token map
- 2. Typography map
- 3. Iconography
- 4. Spacing & layout
- 5. Motion tokens (02 §6)
- 6. Dark/light parity
- 7. Theming hooks (tenant)
Token → widget mapping for the RBAC surface. Base mapping in 00-shared/04; this file extends it. Theme foundation: Material 3, seed
#0037B0(00-shared/02 §1), tenant branding overridesprimaryonly.
1. Component → token map
| Component | Surface | Text | Border/Radius | Elevation | States |
|---|---|---|---|---|---|
| RoleRow / MemberTile | surfaceContainerLow | onSurface / onSurfaceVariant | r-lg, outlineVariant hairline | e-0 | selected primaryContainer (master-detail), hover tint m-fast |
| PermissionMatrix container | surfaceContainerLow | — | r-lg | e-1 | — |
| PermissionGroupSection header | surfaceContainerHigh (sticky, desktop) | titleSmall onSurface | r-md | e-2 sticky | — |
| PermissionChip selected | primaryContainer | onPrimaryContainer | r-full | e-0 | ripple m-fast |
| PermissionChip unselected | surfaceVariant | onSurfaceVariant | r-full | e-0 | ripple m-fast, hover surfaceVariant + 2% |
| System badge | outline border | onSurfaceVariant | r-sm | — | — |
| Perm-count badge | secondaryContainer | onSecondaryContainer | r-sm | — | — |
| Status badge active | secondaryContainer | onSecondaryContainer | r-sm | — | — |
| Status badge suspended | errorContainer | onErrorContainer | r-sm | — | — |
| Status badge invited | tertiaryContainer | onTertiaryContainer | r-sm | — | — |
| Save CTA | primary / onPrimary | labelLarge | r-md | — | disabled 40% opacity |
| Delete (destructive) | errorContainer | onErrorContainer | r-md | — | — |
| FAB "Create role"/"Add member" | primaryContainer | onPrimaryContainer | r-xl (56) | e-3 | hide on scroll-down |
| Slug field text | mono 14 | onSurfaceVariant | — | — | — |
| Info banner (system role, propagation delay) | primaryContainer | onPrimaryContainer | r-md | e-0 | — |
2. Typography map
| Text | Token |
|---|---|
| Screen titles "Roles & Permissions" | headlineMedium |
| Section headers (System/Custom roles, groups) | titleSmall/titleMedium |
| Role/member names | titleMedium (list), titleLarge (detail) |
| Slug, requestId, userId | mono |
| Descriptions, emails, dates | bodyMedium/bodySmall |
| Matrix count "6 of 95" | labelMedium onSurfaceVariant |
| Chip labels | bodySmall |
| Buttons | labelLarge |
3. Iconography
| Icon (Material Symbols outlined; filled for status) | Use |
|---|---|
admin_panel_settings | Roles & Permissions nav entry, role avatar fallback |
shield_person | system role rows |
groups / person_add | members entry / add-member FAB |
lock / lock_open | locked system role / custom role |
manage_search / history | access-audit entry |
check_box tri-state | group select-all (IndeterminateCheckbox) |
search, close, more_vert, edit, delete | standard chrome |
All icons 24 dp default; dense rows 20; status icons filled (00-shared/04 §4).
4. Spacing & layout
- List rows 56 dp; matrix chips
Sp-8gaps; section gapSp-24; card paddingSp-16; bottom action bar paddingSp-16, height 64 (02 §3). - Matrix on tablet/desktop: 2-column group grid (min group width 320 dp); phone: single column.
- Safe areas: bottom bar respects nav bar; sheets
SafeAreatop handle.
5. Motion tokens (02 §6)
| Use | Token |
|---|---|
| Chip toggle, badge count | m-instant |
| Ripple, hover | m-fast |
| Sheet, dialog, section collapse | m-base |
| Hero avatar flight, skeleton fade | m-slow |
| List entrance stagger 40 ms | m-entrance |
| Reduced motion | all → m-instant fade |
6. Dark/light parity
- All colors token-referenced; no literals (04 §7 enforced).
- Dark: chips
surfaceVariantdark variant; selectedprimaryContainerdark variant (dark chromatic roles TBD by designer, 00-shared/02 §1.4); contrast maintained for all badge text (≥ 4.5:1). - Golden tests: every screen light + dark at 3 breakpoints (10_QA_Baseline.md §6).
7. Theming hooks (tenant)
- Org branding
primaryoverride propagates to chips/FAB/CTA automatically (00-shared/04 §7.5); system badges and destructive colours stay token-fixed. - No RBAC-specific color semantics beyond status badges (§1).
12 — API Mapping (RBAC Module)
- 0. Module-wide request envelope & client policy
- 1. The guard pipeline (how every endpoint is protected)
- 2. RBAC endpoints (exact)
- 3. Error-code map (RBAC screens)
- 4. Endpoint coverage audit (forward-looking)
Exact wire contract for every RBAC screen → endpoint. Base
/api/v1; envelope per 00-shared/07 ({success,message,data,meta?,timestamp,requestId}/{success:false,message,error:{code,details?},timestamp,requestId}). All endpoints fromsrc/modules/rbac/rbac.controller.ts; rules fromrbac.service.ts. Tenant identity comes from the JWT claim — never from the body.
0. Module-wide request envelope & client policy
| Aspect | Contract |
|---|---|
| Base | https://api.<domain>/api/v1 |
| Headers | Authorization: Bearer <accessToken>; x-request-id client UUID; Content-Type: application/json |
| Tenancy | tenantId from JWT (jwt-payload.interface.ts:2); body never carries it (RBAC DTOs have no tenant field) |
| Rate tier | default api 100/min (rate-limit.guard.ts:36-37); production-only enforcement (rate-limit.guard.ts:30) |
| Server caching | permissions-of-user: Redis sl:{tenantId}:perm:{userId} EX 300 (rbac.service.ts:48,68) — client must tolerate ≤ 5 min propagation |
| Client caching | roles/members 5 min stale-while-revalidate; permissions catalog 24 h (09 §2) |
| Offline | reads from cache + banner; writes blocked |
| Retry | backoff on 5xx/network; no auto-retry on 429 |
1. The guard pipeline (how every endpoint is protected)
Global order, app.module.ts:129-133:
1. RateLimitGuard (APP_GUARD #1) — sliding window Redis (`rate-limit.guard.ts:20-59`)
2. JwtAuthGuard (APP_GUARD #2) — Bearer verify → req.user {id, tenantId, roles}
+ TenantContext fill, platform_admin flag
(`jwt-auth.guard.ts:40-55`)
3. RbacGuard (APP_GUARD #3) — @Roles (OR, from JWT claims) + @Permissions
(AND, resolved per-user) (`rbac.guard.ts:20-52`)
RbacGuard detail (rbac.guard.ts:20-52):
- no
@Roles/@Permissionsmetadata →true(line 29) — route is authed-only. - role match:
requiredRoles.some(r => req.user.roles.includes(r))(lines 38-42) → else 403ForbiddenException"Insufficient role." - perm match:
getPermissionsForUser(userId, tenantId)(line 44) thenrequiredPerms.every(p => perms.includes(p))(line 48) → else 403 "Insufficient permission." - Decorators:
Roles/ROLES_KEY(roles.decorator.ts:3-4),Permissions/PERMISSIONS_KEY(permissions.decorator.ts:3-4), re-exported byguards/decorators.ts:1-5.
Today's coverage (derived, (planned) for completion): only webhooks
(webhooks.controller.ts:22-86), files (files.controller.ts:30-67), scheduler
(scheduler.controller.ts:24-38), search (search.controller.ts:16) controllers
carry @Permissions. Phase-5 "Permissions audit (all endpoints)"
(docs/IMPLEMENTATION_PLAN.md:241) is (planned); the RBAC controller itself is
role-gated (rbac.controller.ts:21). Client must be built to the intended model
(perm-gated everywhere) while living with the current role-gated reality (OQ-R1).
2. RBAC endpoints (exact)
All under class gate @Roles('org_admin') + @UseGuards(JwtAuthGuard, RbacGuard)
(rbac.controller.ts:21-22).
Roles list (S1, S2)
| Endpoint | GET /rbac/roles (rbac.controller.ts:27-31) |
| Success | 200 data: Role[] — array, no meta (not paginated, rbac.service.ts:75-77) |
| Sort | priority: -1 desc (rbac.service.ts:76) |
| Role doc shape | {_id, tenantId, name, slug, description?, isSystem, priority, permissions[], createdAt, updatedAt, version} (role.schema.ts:67-86, base.schema.ts) |
| Errors | 401 UNAUTHENTICATED; 403 PERMISSION_DENIED (non-org_admin); 429 RATE_LIMITED; 5xx |
Permission catalog (S3 matrix)
| Endpoint | GET /rbac/permissions (rbac.controller.ts:33-37) |
| Success | 200 data: string[] — the 95 names (rbac.service.ts:79-81; permissions.constants.ts:1-97) |
| Errors | same as above |
Create role (S3)
| Endpoint | POST /rbac/roles (rbac.controller.ts:39-43) |
| Request | CreateRoleDto: {name, slug, description?, isSystem?, priority?, permissions?} (create-role.dto.ts:10-38) |
| Success | 201/200 data: Role (created with tenant from context, role.repository.ts:17-22) |
| Errors | 400 VALIDATION_ERROR (field details); 409 DUPLICATE_RESOURCE — slug exists (rbac.service.ts:86-87, index role.schema.ts:89); 403; 429 |
| Client note | always send isSystem:false (OQ-R2); whitelist permissions client-side |
Update role (S3 edit)
| Endpoint | PATCH /rbac/roles/:id (rbac.controller.ts:45-49) |
| Request | same CreateRoleDto (full-doc $set, rbac.service.ts:96) |
| Errors | 400 "Cannot modify system roles." (rbac.service.ts:94-95); 404 RESOURCE_NOT_FOUND (rbac.service.ts:93,97); 409 on slug clash; 403 |
| Success | 200 data: Role (updated doc) |
Delete role (S1 menu)
| Endpoint | DELETE /rbac/roles/:id (rbac.controller.ts:51-55) |
| Behaviour | soft delete (rbac.service.ts:106); 400 system role (rbac.service.ts:104-105); 404 unknown (rbac.service.ts:103) |
| Success | 200 data: <void> (empty) |
Members list (S4)
| Endpoint | GET /rbac/members (rbac.controller.ts:57-61) |
| Success | 200 data: Member[] — array, no meta (rbac.service.ts:109-111) |
| Sort | joinedAt: -1 desc (rbac.service.ts:110) |
| Member doc shape | {_id, tenantId, userId, organizationId?, roles[], permissions[], status, joinedAt, invitedBy?, acceptedAt?, lastActiveAt?, metadata?, createdAt, updatedAt, version} (organization-member.schema.ts:14-44) |
| Note | no user-profile join (OQ-R7); permissions[] field exists but is never written by the service (derived perms only, rbac.service.ts:63-65) |
Add member (S5)
| Endpoint | POST /rbac/members (rbac.controller.ts:63-67) |
| Request | AddMemberDto: {userId (MongoId), roles: string[]} (add-member.dto.ts:4-12) |
| Behaviour | creates status:"active", joinedAt: now, tenant-scoped (rbac.service.ts:113-127) |
| Errors | 400 validation; 403; 500 on duplicate (E11000 on {tenantId,userId} index organization-member.schema.ts:48 — unmapped, OQ-R5) → client pre-checks |
| Success | 200/201 data: Member |
Update member roles (S4 menu edit, S5 edit mode)
| Endpoint | PATCH /rbac/members/:id (rbac.controller.ts:69-73) |
| Request | AddMemberDto — userId ignored by service (rbac.controller.ts:72; rbac.service.ts:129-136 $set:{roles}) |
| Errors | 404 RESOURCE_NOT_FOUND (rbac.service.ts:134); 400; 403 |
| Success | 200 data: Member |
Remove member (S4 menu)
| Endpoint | DELETE /rbac/members/:id (rbac.controller.ts:75-79) |
| Behaviour | soft delete; silent 200 even if not found (rbac.service.ts:138-140) |
| Success | 200 data: <void> |
Permission audit (S6) — cross-module read
| Endpoint | GET /audit-logs?page=&limit=&action=&entityType=&actorId= (audit.controller.ts:17-34) |
| Guard | JwtAuthGuard only today (audit.controller.ts:9) — audit.read not enforced (Phase-5 (planned); client gates anyway) |
| Success | 200 paginated: data: AuditLog[] + meta: {page, limit, totalItems, totalPages, hasNext, hasPrevious} (07 §2; default limit 50, audit.controller.ts:20) |
| Filter | action / entityType / actorId combined (audit.controller.ts:26-29) |
3. Error-code map (RBAC screens)
| Screen | code | UI |
|---|---|---|
| any list | 401 | silent refresh → fail → sessionExpired |
| any | 403 PERMISSION_DENIED | 403 screen w/ cause copy; hide offending action |
| role save | 409 DUPLICATE_RESOURCE | inline under slug + focus |
| role save/delete | 400 (system role) | unreachable via UI; banner fallback |
| role update/delete | 404 | treat as removed → pop to list + snackbar |
| member add | 500 (dup, E11000) | pre-checked; fallback conflict banner + list refresh |
| member update | 404 | row removed snackbar |
| any write | 429 | countdown banner, no auto-retry |
| any | 5xx | generic + requestId (http-exception.filter.ts:60-65) |
4. Endpoint coverage audit (forward-looking)
| Controller group | Status today | Source |
|---|---|---|
| webhooks (11) | @Permissions('webhook.*') on all | webhooks.controller.ts:22-86 |
| files (5) | @Permissions('file.*') on all | files.controller.ts:30-67 |
| scheduler (3) | @Permissions('scheduler.*') on all | scheduler.controller.ts:24-38 |
| search (1) | @Permissions('search') | search.controller.ts:16 |
| rbac (8) | @Roles('org_admin') class-level | rbac.controller.ts:21 |
| all other modules | global guards only (authed) | app.module.ts:129-133 |
| full permission audit | (planned) Phase 5 | docs/IMPLEMENTATION_PLAN.md:241 |
13 — State Management (RBAC Module)
- 1. Cubits
- 2.
PermissionMirror— client-side permission state (core) - 3. Cross-screen data flow
- 4. Guard composition (client routes)
- 5. Editor state persistence
- 6. Member merge strategy
- 7. Optimistic updates — allowed list
- 8. Realtime & refresh
- 9. Testing hooks
Cubit architecture for the RBAC surface, on 00-shared/06. Server model recap: JWT carries
rolesclaim (minted at login,auth.service.ts:145-154); effective permissions resolve via Redis-cachedgetPermissionsForUser(rbac.service.ts:44-73); the client mirrors this with its own permission cache.
1. Cubits
| Cubit | State | API |
|---|---|---|
RoleListCubit | LoadState + List<Role> + searchQuery | GET /rbac/roles |
RoleEditorCubit | LoadState + Role? + Set<String> selected + List<String> catalog + formFields + saving | GET /rbac/permissions, POST /rbac/roles, PATCH /rbac/roles/:id |
MemberListCubit | LoadState + List<MemberView> + searchQuery | GET /rbac/members + user.read merge |
MemberSheetCubit | LoadState + User? selected + Set<String> roles + saving + editMode | POST /rbac/members, PATCH /rbac/members/:id, GET /rbac/roles (picker) |
AuditCubit | LoadState + List<AuditRow> + filters + PaginatedListMixin | GET /audit-logs |
All follow LoadState (00-shared/06 §3.1); audit uses PaginatedListMixin (§3.2).
Events: Load, Refresh, Retry, ChangeSearch, TogglePermission,
ToggleGroup, Save, ClearFilters — naming per 00-shared/06 §4.
2. PermissionMirror — client-side permission state (core)
The client's analog of rbac.service.ts:44-73:
class PermissionMirror {
// key: 'sl:{tenantId}:perm:{userId}' — mirrors server key shape (rbac.service.ts:48)
final Map<String, List<String>> _cache; // in-memory
final HiveCache _hive; // persistent, per tenant
// stale-while-revalidate: serve cached, refresh ≤ 300 s TTL (server EX 300,
// rbac.service.ts:68), rebuild on miss
Future<Set<String>> permissionsFor(String userId, String tenantId);
void invalidate(String tenantId, String userId); // after role/member edits by self
}
Rules:
- Server is authoritative. The mirror is a cache, never a decision-maker — every critical action still surfaces server 403s.
- TTL mirrors the server: ≤ 300 s freshness;
invalidateson own writes (create/ update/delete role, add/update/remove member) and on login. - Route rebuild: when the mirror resolves a changed permission set for the current
user (login, manual refresh, own RBAC write, session resume), emit
permissionsChanged→AppRouter.refresh()→ hidden/unroutable destinations update (05 §9,11_Flutter_App_Architecture.md §6). - JWT roles claim is separate (
jwt-payload.interface.ts:3):AuthCubitholds it; role-claim changes require re-login (OQ-R4) — the client offers "Refresh session" after self-role edits (09 §3).
3. Cross-screen data flow
AppShell (05 §1)
├─ AuthCubit → roles claim (JWT) + userId + tenantId
├─ PermissionMirror → Set<String> myPermissions (SWR, 300 s)
└─ FeatureFlagsCubit → gates coaching perms (planned) — render only when server ships
Router guards (permissionGuard('rbac.role.read'), …) — 15 §5
│
▼
RBAC screens → module cubits (above) → repositories (dio) → API
- Shared selectors:
currentUser,currentTenant(00-shared/06 §4). - No global singleton holds screen state; cubits are
get_itlazy factories.
4. Guard composition (client routes)
Current server reality (role-gated /rbac/*, rbac.controller.ts:21) vs intended
perm-gated model → combined guard (see 04 §3):
roleOrPermissionGuard(roles: ['org_admin'], permissions: ['rbac.role.read'])
Resolves true if the JWT claims org_admin or the mirror grants rbac.role.read —
removes screens for both the teacher (no role, no perm) and the future role-manager
(perm yes, role no) as OQ-R1 evolves. Audit route: permissionGuard('audit.read')
(server lags, audit.controller.ts:9 — (planned)).
5. Editor state persistence
RoleEditorCubitholdsselected(Set) across backgrounding/session expiry (in-memory; survives re-login per 09 §9).- Dirty tracking:
formFields != originalorselected != role.permissions→ back confirm (10 §4). - Save: one PATCH with the full array (
rbac.service.ts:96); no per-cell writes.
6. Member merge strategy
MemberListCubit fetches GET /rbac/members + GET /api/v1/users (user.read, cached
24 h) → MemberView {member, user?}; missing user → fallback tile (userId mono,
06 §S4). Merge is display-only; writes send member.userId unchanged (OQ-R7).
7. Optimistic updates — allowed list
| Action | Optimistic? | Rule |
|---|---|---|
| Toggle permission / group | yes (local set) | single PATCH on Save; rollback on error |
| Add member | no | server-confirm, then insert row |
| Remove member / delete role | no | server-confirm, then remove row (irreversible-ish, 09 §4) |
| Role perm count badge | yes | derived from editor state |
8. Realtime & refresh
- No WS topic for RBAC changes exists (server
WsModuletopics list has none —00-shared/07 §8; OQ-R9). Cross-admin freshness = pull-to-refresh + stale-while- revalidate only. - On app resume: refetch roles/members lists if stale > 5 min; re-validate mirror.
9. Testing hooks
- Cubits pure-Dart with mocked repositories;
PermissionMirrorunit-tested for SWR/TTL/invalidate semantics; widget tests for 3-state screens (00-shared/06 §6).
14 — QA Checklist (RBAC Module)
- 1. Permission matrix — edge cases
- 2. Roles CRUD & custom-role persistence
- 3. Members & 403 behaviour
- 4. Tenant isolation
- 5. Privilege-escalation scenarios (security focus)
- 6. Wire-contract checks
- 7. Performance & a11y (from 00-shared/10, RBAC-specific)
- 8. Regression hooks (test plan linkage)
Module-specific QA on top of 00-shared/10. Every scenario below traces to a server behaviour (
rbac.service.ts,rbac.guard.ts,role.schema.ts,organization-member.schema.ts) or a wire-contract rule. Cross-ref:PLAN.mdrows 1.5 (PLAN.md:17), 10.1-10.5 (PLAN.md:106-110), 12.x (PLAN.md:127-132), 14.x (PLAN.md:146-148), 19.x (PLAN.md:189-191).
1. Permission matrix — edge cases
| # | Scenario | Expected | Source |
|---|---|---|---|
| M1 | Catalog has exactly 95 perms; matrix renders all 26 groups; count badge = 95 | no truncation, scrollable | permissions.constants.ts:1-97 |
| M2 | Toggle all in group → group checkbox checked; toggle one → indeterminate | tri-state correct | 06 §3 |
| M3 | Search "pay" → only payments/receipts group + perms; 0-match groups collapse | filter correct; liveRegion count | 07 §1-2 |
| M4 | Save with empty permissions → warning shown, POST succeeds (role like student, role.schema.ts:63) | empty allowed | 08 §1 |
| M5 | Permission string not in catalog (client injection attempt) → blocked client-side | whitelist enforced in UI | server @IsArray only, create-role.dto.ts:36 |
| M6 | Role with 95 perms (clone org_admin) → save round-trip < 1.5 s p95 | single PATCH | rbac.service.ts:96 |
| M7 | Rapid toggling while saving → anti-double-submit; state not corrupted | disabled during save | 10 §4 |
| M8 | 95-perm matrix at text scale 2× → no clipping; chips wrap | 00-shared/09 §4 | — |
| M9 | Matrix in dark mode — chip contrast ≥ 4.5:1 | token check | 11 §6 |
2. Roles CRUD & custom-role persistence
| # | Scenario | Expected | Source |
|---|---|---|---|
| R1 | Create role → appears in Roles list under Custom section; persists across app restart (server fetch) | persistence | rbac.service.ts:83-89 |
| R2 | Create with existing slug → 409 inline, no state loss (form preserved) | error mapping | rbac.service.ts:86-87 |
| R3 | Create without forcing isSystem:false (bug path) → server would lock; UI always sends false | regression guard (OQ-R2) | role.schema.ts:78-79 |
| R4 | Edit custom role perms → PATCH 200 → detail refreshed; holders see change ≤ 5 min (cache) | propagation banner | rbac.service.ts:68,96 |
| R5 | System role edit/delete → UI blocks (no editor, no delete); API 400 fallback shown if reached | locked | rbac.service.ts:94-95,104-105 |
| R6 | Delete custom role → soft-deleted; gone from list; members keep stale slug chip (render-only) | rbac.service.ts:106; 09 §2 | |
| R7 | Role slug immutability communicated; PATCH slug would orphan member arrays — UI treats as immutable | 08 §1 | — |
| R8 | Duplicate slug on update → 409 handled identically to create | rbac.service.ts:86-87 (create path only — update path 409 via unique index; OQ-R11) | — |
3. Members & 403 behaviour
| # | Scenario | Expected | Source |
|---|---|---|---|
| B1 | Non-org_admin (e.g. teacher JWT) calls /rbac/roles → 403 PERMISSION_DENIED | envelope; no data | rbac.controller.ts:21, rbac.guard.ts:38-42 |
| B2 | Client routes hidden for teacher; direct deep link → 403 screen with cause copy + requestId | PLAN.md:17 (1.5) | 04 §3, 06 §S6 |
| B3 | Member with perm rbac.member.read but not org_admin → today 403 (OQ-R1) — UI must not promise access | flagged, not silent | rbac.controller.ts:21 |
| B4 | Add duplicate member → client pre-check blocks; if server 500 slips through → conflict banner + refresh (OQ-R5) | no silent error | organization-member.schema.ts:48 |
| B5 | Remove member → row gone; re-fetch confirms isDeleted filtered (base.repository.ts:20-30) | rbac.service.ts:138-140 | — |
| B6 | Update member roles → PATCH 200; chips update; userId sent but ignored (DTO compat) | rbac.controller.ts:71-72 | — |
| B7 | Remove own org_admin → typed confirm; after removal JWT still grants access until expiry — banner explains re-login | 09 §4 | — |
4. Tenant isolation
| # | Scenario | Expected | Source |
|---|---|---|---|
| T1 | Tenant A admin lists roles/members → zero Tenant B docs (repo scoping) | base.repository.ts:20-30; PLAN.md:106 (10.1) | — |
| T2 | Tenant B user uses Tenant A JWT → 401/403 by token claims | jwt-auth.guard.ts:40-55; PLAN.md:107 (10.2) | — |
| T3 | Role slug collision across tenants → both create successfully (unique index is {tenantId, slug}) | role.schema.ts:89 | — |
| T4 | Member {tenantId,userId} unique per tenant — same user in 2 tenants = 2 memberships (supported model, COLLECTIONS.md:554-570) | organization-member.schema.ts:48 | — |
| T5 | Permission cache keys namespaced sl:{tenantId}:perm:{userId} — no cross-tenant cache leak | rbac.service.ts:48; PLAN.md:109 (10.4) | — |
| T6 | platform_admin reads across tenants via repo bypass — UI must not render tenant RBAC data cross-tenant without an org_admin token | base.repository.ts:21; 02 §4 | — |
| T7 | seedDefaults(org.slug) vs seedDefaults(user.tenantId) inconsistency — verify tenantId == slug after org-created flow (OQ-R6) | organizations.service.ts:58, auth.service.ts:93 | — |
5. Privilege-escalation scenarios (security focus)
| # | Attack / scenario | Defence | Source |
|---|---|---|---|
| P1 | Client sets permissions: ['organization.delete'] on own role via PATCH | Server: org_admin-role gate only; once perms are enforced (Phase-5 planned) the guard rejects. Client: matrix whitelist. Today: any org_admin can craft any body — inherent to role-gated admin; documented. | rbac.guard.ts:43-49, docs/IMPLEMENTATION_PLAN.md:241 |
| P2 | Client sets isSystem:true on create to lock role (no privilege gain — lock is restrictive) | no vector; still forced false in UI | create-role.dto.ts:27, OQ-R2 |
| P3 | Create role with slug org_admin → 409 (unique) — cannot shadow system role | role.schema.ts:89 | — |
| P4 | Crafted perm string '*' or unknown 'a.b.c' in roles → server stores it; guard every() fails for real endpoints (no grant) — matrix whitelist prevents UI path | whitelist | rbac.guard.ts:48 |
| P5 | Member with removed role still calls role-checked endpoint → JWT claim valid until token expiry (≤ 15 m access) — document in security review (OQ-R4) | token TTL | env.ts (15 m access), auth.service.ts:192-196 |
| P6 | platform_admin tries /rbac/* → 403 (not org_admin) — no tenant cross-read via RBAC UI | rbac.controller.ts:21 | — |
| P7 | Audit: RBAC writes must appear in audit-logs — verify action strings recorded for role/member CRUD (exact strings OQ-R8) | PLAN.md:189-191 (19.x) | — |
6. Wire-contract checks
| # | Check |
|---|---|
| W1 | Success envelope: {success:true, message:"OK", data, meta?, timestamp, requestId} — arrays without meta for roles/members; with meta for audit |
| W2 | Error codes: 400 VALIDATION_ERROR (field details), 403 PERMISSION_DENIED, 404 RESOURCE_NOT_FOUND, 409 DUPLICATE_RESOURCE, 429 RATE_LIMITED, 5xx INTERNAL_SERVER_ERROR |
| W3 | requestId echoed; client-generated x-request-id honored |
| W4 | CastError (bad :id) → 400 "Invalid resource identifier." |
| W5 | 429 on RBAC in production only; countdown copy; no auto-retry |
7. Performance & a11y (from 00-shared/10, RBAC-specific)
- Matrix first frame < 300 ms (cached catalog), scroll 60 fps on mid-range device;
RepaintBoundaryper group (15 §2). - TalkBack: matrix chip toggle announces group + checked state; audit table headers announced; typed self-removal confirm readable.
- Text scale 2× on matrix/roles/members; dynamic type no clipping (
M8). - Dark/light golden per screen (S1–S6).
8. Regression hooks (test plan linkage)
- Unit:
PermissionMirrorTTL/invalidate; matrix tri-state logic; slug validator. - Widget: S1–S6 × {loading, error, empty, content}; 403 screen.
- Integration: J1 (create role), J3 (add member), J4 (accountant/HR custom role),
J5 (guard-denied) — mapped to
PLAN.md:17(1.5),PLAN.md:107-110(10.x). - E2E: cross-tenant isolation (T1–T6) with two seeded tenants.
15 — Flutter Implementation Guide (RBAC Module)
- 1. Folder structure
- 2. PermissionMatrix — performance spec (hero)
- 3. Permission-mirror caching (
13 §2) - 4. Route guards (
go_router) - 5. Repository & networking
- 6. i18n & error copy
- 7. Tests
- 8. Performance checklist (RBAC-specific)
Implementation guidance on top of 00-shared/11. Forward-looking: no client repo yet. Focus areas: matrix widget performance, permission-mirror caching, route guards, plus module folder structure, DTOs, and tests.
1. Folder structure
lib/features/rbac/
├── data/
│ ├── dto/role_dto.dart # RoleDto.fromJson (role.schema.ts:67-86 shape)
│ ├── dto/member_dto.dart # MemberDto.fromJson (organization-member.schema.ts:14-44)
│ ├── dto/permission_dto.dart # plain String list (GET /rbac/permissions)
│ ├── models/role.dart # domain: name, slug, isSystem, priority, Set<String> permissions
│ ├── models/member_view.dart # member + merged UserProfile (OQ-R7)
│ └── repositories/
│ ├── rbac_repository.dart # roles + members + catalog calls
│ └── permission_mirror.dart # client-side permission cache (13 §2)
├── domain/
│ └── rbac_permissions.dart # group mapping (26 groups, 06 §3) + humanized labels (i18n)
└── presentation/
├── cubit/role_list_cubit.dart / role_editor_cubit.dart /
│ member_list_cubit.dart / member_sheet_cubit.dart / audit_cubit.dart
├── pages/roles_page.dart / role_detail_page.dart / role_editor_page.dart /
│ members_page.dart / access_audit_page.dart / permission_denied_page.dart
└── widgets/ permission_matrix.dart / permission_group_section.dart /
permission_chip.dart / role_row.dart / member_tile.dart /
role_picker_chips.dart / user_search_picker.dart / audit_row.dart
DTOs map envelope payloads (00-shared/11 §4); widgets consume models only.
2. PermissionMatrix — performance spec (hero)
The requirement: 95 chips in 26 groups, instant toggle, 60 fps scroll on a
mid-range phone (00-shared/10 §1).
- Lazy build: one
ListView.builderof group sections — never aColumnof 95 chips (build cost would blow the 8 ms frame budget on scroll). RepaintBoundaryperPermissionGroupSection— toggling a chip repaints one section, not the whole matrix.- Const everywhere: chips are
const-constructible (label, selection state asSet<String>membership check) →FilterChipinsideWrap;Wrapis fine per group (≤ 13 chips,transport.*max) but the matrix must not be one giantWrap. - Search: precomputed group→matches index; filter collapses sections without touching the list (no rebuild of chips).
- State:
selectedasSet<String>(hash membership O(1)); group tri-state computed indidUpdateWidget-style recalculation, memoized per group. - Save payload: single
List<String>—jsonEncodeof the set at Save only. - Golden tests: 3 sizes × light/dark × editable/readOnly with the full 95-perm fixture; profile frame-build < 16 ms on reference device.
(planned)coaching perms (docs/IMPLEMENTATION_PLAN.md:751-761) render only when the server catalog includes them — group list must tolerate unknown prefixes (fallback group "Other").
3. Permission-mirror caching (13 §2)
- In-memory
Map<String, List<String>>keyed'sl:{tenantId}:perm:{userId}'(mirrorsrbac.service.ts:48) + Hive persistence per tenant (keyperm_mirror:{tenantId}),shared_preferencestoo small for 95-string lists. - Stale-while-revalidate: serve cached instantly; background refetch; update on
success; TTL ≤ 300 s aligned to server EX 300 (
rbac.service.ts:68). - Invalidate on: login/logout, own RBAC writes (role save/delete, member add/edit/remove), manual "Refresh session".
- No mirror → gate open? No: on cold start with empty mirror, routes resolve
optimistically against cached JWT roles; actual enforcement is server-side. 403s
surface through the error pipeline (
00-shared/06 §5) and the mirror re-syncs. - Never ship the 95 names as a hardcoded client constant — catalog must come from
GET /rbac/permissions(rbac.service.ts:79-81); mirror is cache, catalog is data.
4. Route guards (go_router)
final rbacRoutes = [
GoRoute(path: '/settings/roles', redirect: _rbacGuard('rbac.role.read'), ...),
GoRoute(path: '/settings/members', redirect: _rbacGuard('rbac.member.read'), ...),
GoRoute(path: '/settings/access-audit', redirect: _rbacGuard('audit.read'), ...),
];
String? _rbacGuard(String perm) => (context) {
final auth = di<AuthCubit>().state;
final mirror = di<PermissionMirror>();
final ok = auth.roles.contains('org_admin') // server reality (rbac.controller.ts:21)
|| mirror.has(auth.tenantId, auth.userId, perm); // intended perm model (OQ-R1)
return ok ? null : '/settings/403';
};
- Role-or-permission composition per
04 §3/13 §4— covers both the current role-gated server and the perm-gated future without a client rewrite. - Route rebuild on change:
PermissionMirror.permissionsChangedstream →AppRouter.refresh()(05 §9); rebuild also onAuthCubitauth change (re-login with new roles claim). - Deep-link to removed route → 403 screen (never blank); session-expiry overlay wins
over 403 (401 takes precedence,
00-shared/06 §3.6).
5. Repository & networking
RbacRepositorymethods:listRoles(),listPermissions(),createRole(dto),updateRole(id, dto),deleteRole(id),listMembers(),addMember(dto),updateMemberRoles(id, dto), plusqueryAudit(filters)(audit-logs via00-shared/11 §5AppDio).- Envelope mapping → typed
ApiException(code, status, message); 409 →Duplicatesurfaced inline; 500-on-duplicate-member pre-checked (08 §2). Idempotency-Keyheader onPOST /rbac/members(write-once op,00-shared/07 §9).
6. i18n & error copy
- Keys:
rbac.title,rbac.role.create,rbac.matrix.count("{sel}","{total}"),rbac.system.role.locked,rbac.propagation.note,rbac.self.remove.confirm,perm.{group}.{name}(humanized labels, fallback raw string), 403 screen copy. - Server messages (e.g. "Cannot modify system roles.") rendered only as fallback for
business 4xx (
00-shared/07 §11).
7. Tests
- Unit: mirror TTL/invalidate; matrix tri-state; slug validator
^[a-z0-9_]{2,40}$; group mapping of all 95 perms (fixture frompermissions.constants.ts:1-97). - Widget: S1–S6 × {loading, error, empty, content}; matrix golden (3 sizes, dark/light); 403 page.
- Integration: J1 create-role → assign → member add → re-login (J4); guard-denied (J5) with mocked 403 envelope.
- E2E: tenant isolation with two tenants (T1–T6); permission propagation ≤ 5 min (M4/R4).
8. Performance checklist (RBAC-specific)
- Matrix first frame < 300 ms with cached catalog; scroll 60 fps profiled
- No rebuild of all 95 chips on single toggle (RepaintBoundary verified)
- Mirror reads synchronous from memory (no disk read on hot path)
- 95-perm PATCH serialization < 10 ms
-
Memory: no growth across 20 min matrix session (
00-shared/10 §1)
01 — Product Overview (Reports Module)
- 1. Purpose
- 2. Business goals
- 3. Scope — implemented today
- 4. Out of scope / not yet implemented
- 5. Platform context
- 6. PRD native-app exclusion (flagged)
- 7. Success metrics
- 8. Glossary (module-specific)
Async report generation and export. Client-facing definition of the Reports module against the NestJS backend. Source of truth:
studylyon-blueprint/04-Modules/Reports.mdandsrc/modules/reports/**. Shared platform contract: 00-shared/01.
1. Purpose
Reports lets institution users request heavy operational/academic data extractions
(report cards, attendance summaries, fee summaries) that are generated
asynchronously — the request returns a jobId immediately and the client polls
until the job completes, then downloads or views the result.
Key rule from blueprint (studylyon-blueprint/04-Modules/Reports.md:48):
"All generation is async (202 Accepted + job polling)."
2. Business goals
| Goal | Measure |
|---|---|
| Long-running generation never blocks the API | POST returns in < 500 ms with jobId |
| Users see progress, not blank screens | job status surfaced: queued → processing → completed/failed |
| Results are repeatable | job doc keeps params + result; re-request is cheap |
| Tenant isolation | every job doc carries tenantId; scoped via BaseRepository |
| No cross-role data leaks | report.generate / report.read permissions defined (permissions.constants.ts:39-40) |
3. Scope — implemented today
- 3 report types (
report-job.schema.ts:7-11):report_card,attendance_summary,fee_summary. - Job lifecycle (
report-job.schema.ts:13-18):queued→processing→completed|failed, witherrormessage andcompletedAttimestamp. - 2 endpoints (
reports.controller.ts:14-24):POST /reports/generate,GET /reports/:jobId(status + JSON result polling). - BullMQ worker on
report-generatequeue (report.worker.ts:7), tenant context restored per job. - Scheduled generation:
AttendanceReportJobenqueues daily/weekly attendance reports into the samereport-generatequeue (scheduler/jobs/attendance-report.job.ts:13-24).
4. Out of scope / not yet implemented
GET /reports/:jobId/downloadexists only in the blueprint (04-Modules/Reports.md:26) — no controller route, no PDF/CSV serialization, no storage integration in code. Marked(planned)everywhere in this package.- PDF / Excel / CSV export (
04-Modules/Reports.md:14), streamed downloads (:49), file storage undersl/{tenantId}/reports/{uuid}with TTL cleanup (:50) —(planned). ReportGenerateddomain event and completion notifications (04-Modules/Reports.md:33,42) —(planned); no report route exists inevent-queue-map.ts.- Coaching report types
BATCH_PERFORMANCE,TEST_SERIES_ANALYSIS,DPP_COMPLETION(docs/IMPLEMENTATION_PLAN.md:646) —(planned). - Templates, scheduling UI, email delivery, custom SQL
(
docs/IMPLEMENTATION_PLAN.md:233) —(planned).
5. Platform context
- Data sources (read-only): Students, Attendance, Fees/Invoices, Results —
wired in
reports.module.ts:20-23. - Queue:
QUEUE.REPORT_GENERATE = 'report-generate'(infrastructure/bullmq/queue.constants.ts:10). - Files module exists (
GET /files/:id/download,files.controller.ts:55-64) and is the intended delivery path for generated files once PDF export lands — today report results are JSON stored on the job doc (report-job.schema.ts:36).
6. PRD native-app exclusion (flagged)
00-shared/01 §9: PRODUCT_REQUIREMENTS_DOCUMENT.md:144 keeps native mobile apps
out of Phase 1; roadmap Phase 3 plans a read-only companion. Decision made with
the product owner: these docs specify a full-featured Flutter client now, to the
complete API surface. This package follows that decision; screens assume an
authenticated, permission-gated mobile/tablet client.
7. Success metrics
- POST →
jobIdround trip < 500 ms (excluding queue time). - Job status poll → visible state within one poll interval (default 2 s).
- P95 time-to-complete: report_card < 5 s, attendance/fee summaries < 2 s on reference dataset (measured per tenant size class).
- 0% cross-tenant reads;
report.readenforced on every result read.
8. Glossary (module-specific)
| Term | Meaning |
|---|---|
| Report job | report_jobs document: type, status, params, result, error, completedAt |
| ReportType | report_card, attendance_summary, fee_summary |
| Job status | queued / processing / completed / failed |
| Polling contract | GET /reports/:jobId until completed/failed, then render/download |
| DLQ | Dead-letter queue for failed BullMQ jobs (global contract, 00-shared/01 §10) |
02 — User Personas (Reports Module)
- P1 — Org Admin (school/coaching owner)
- P2 — Principal / Head of Academics
- P3 — Teacher / Class Teacher
- P4 — Accountant / Finance staff
- P5 — Super Admin (platform)
- Permission → screen map (client-side enforcement)
Who uses Reports, with what permissions, for which report types. Permissions from
permissions.constants.ts:39-40(report.generate,report.read). Roles from 00-shared/01 §3. All three report types (report-job.schema.ts:7-11) map to read-only consumers.
P1 — Org Admin (school/coaching owner)
- Goals: institution-wide visibility; run fee summaries at month end; verify report cards before publishing; delegate.
- Permissions:
report.generate+report.read(permissions.constants.ts:39-40). - Reports used:
fee_summary(primary),attendance_summary(school-wide or per class),report_card(spot checks). - Pain points: spreadsheets and manual counting; long wait times with no feedback.
- Key jobs-to-be-done: "Show me what's uncollected", "Is attendance healthy this term?", "Sanity-check a report card before results go out".
- Devices: tablet/desktop mostly; mobile for status checks.
- Behavior: requests reports from dashboard quick actions; checks job list periodically; downloads for archival.
P2 — Principal / Head of Academics
- Goals: academic quality monitoring; term-end report cards; attendance trends.
- Permissions:
report.read(view) —report.generateoptional(forward-looking)until RBAC roles are seeded per permission set. - Reports used:
report_card(term-end batches),attendance_summary. - Key jobs-to-be-done: "Verify marks/grades before parent communication", "Spot classes with deteriorating attendance".
- Behavior: reviews job list daily at term end; views results in-app rather than downloading.
P3 — Teacher / Class Teacher
- Goals: per-class attendance summaries and individual report cards; quick pull for parents' meetings.
- Permissions:
report.generate+report.readper role grants(forward-looking)— today the API is JWT-gated only (reports.controller.ts:9), so the client must enforce menu visibility by permission list until RBAC guards land. - Reports used:
attendance_summary(withclassId),report_card(studentId+examId). - Pain points: queueing time for term-end batch cards; wants progress, not a spinner.
- Key jobs-to-be-done: "Attendance for my class this month", "This student's report card for the mid-term exam".
- Devices: mobile-first.
P4 — Accountant / Finance staff
- Goals: collections visibility; overdue tracking; monthly reconciliation input.
- Permissions:
report.readminimum;report.generatewhere granted. - Reports used:
fee_summary—totalInvoices, totalCollected, totalPending, totalOverdue(reports.service.ts:154-158). - Key jobs-to-be-done: "How much is outstanding?", "Track monthly collection".
- Behavior: schedules weekly fee summaries (scheduler
(planned)for admin UI; todayAttendanceReportJobis daily/weekly attendance only,attendance-report.job.ts:6).
P5 — Super Admin (platform)
- Goals: platform health; never touch tenant data (00-shared/01 §3).
- Permission: platform bypass of tenant scope (
base.repository.ts:21-23viaisPlatformAdmin) — out of product scope; no report screens.
Permission → screen map (client-side enforcement)
| Permission | Screens |
|---|---|
report.read | Catalog (view list), Job list, Job detail, Result view/download |
report.generate | Generate form, "New report" entry points |
| neither | Reports module hidden ((forward-looking) — guard gap, 12_API_Mapping §9) |
03 — User Journey (Reports Module)
- J1 — Generate a fee summary (Org Admin / Accountant)
- J2 — Generate an attendance summary for a class (Teacher)
- J3 — Generate a report card (Teacher / Principal)
- J4 — Scheduled report lands in job list (any role with
report.read) - Journey map
- Common failure paths
End-to-end journeys through the async generate → poll → view/download flow. All three journeys share the same server contract:
POST /reports/generate→{jobId, status:'queued'}(reports.service.ts:28-44), then pollGET /reports/:jobId(reports.service.ts:46-50) untilcompleted/failed(report-job.schema.ts:13-18).
J1 — Generate a fee summary (Org Admin / Accountant)
- Entry: Dashboard → "Reports" → "New report".
- Form: select type
fee_summary; no required params (generate-report.dto.ts:6-33— all params optional). - Submit →
POST /reports/generate(reports.controller.ts:14-18).- Success: navigate to Job detail with
jobId, statusqueued. - Error 400/429/5xx: inline error, form preserved.
- Success: navigate to Job detail with
- Poll: client polls
GET /reports/:jobIdevery 2 s (reports.service.ts:46-50). - Completed: result card with
totalInvoices, totalCollected, totalPending, totalOverdue(reports.service.ts:154-158); CTA "Download CSV"(planned). - Failed: status
failedwitherror(report-job.schema.ts:39); CTA "Retry" = re-POST same params (new job) —reports.service.ts:31-41. - Exit: back to job list; job persists for later reads.
Happiness metric: request → visible progress < 1 s; result < 5 s.
J2 — Generate an attendance summary for a class (Teacher)
- Entry: Reports catalog →
attendance_summarytemplate. - Form:
classIdoptional,startDate/endDateoptional (generate-report.dto.ts:17-33); no dates = all records (reports.service.ts:117-123). - Submit/poll as J1.
- Completed: summary of counts per attendance status
(
reports.service.ts:126-129) +total+ period (reports.service.ts:131-136). - View: status-pie (present/late/absent/excused) + totals.
- Edge: empty range →
{total: 0, summary: {}}— render empty state, no error.
J3 — Generate a report card (Teacher / Principal)
- Entry: Student profile → "Report card" → prefill
studentId. - Form:
studentId+examIdrequired by the service, not the DTO — missing → jobfailedwitherror: "studentId and examId required"(reports.service.ts:91). DTO only enforces Mongo-Id format (generate-report.dto.ts:11-13,22-23). Client MUST validate presence before POST (gap flagged in 08). - Submit/poll as J1.
- Completed: card data —
studentName(note: currently the admission number,reports.service.ts:98),subjects[],totalMarksObtained,totalMaximumMarks,percentage,grade(reports.service.ts:96-107). Grade bands A+…D fromresult.service.ts:130-136. - View: subject table + grade badge; "Share as PDF"
(planned)— the blueprint's PDF export does not exist yet. - Student not found →
failedwitherror: "Student not found"(reports.service.ts:94); UI shows retry.
J4 — Scheduled report lands in job list (any role with report.read)
- Entry: scheduler fires daily/weekly attendance report
(
attendance-report.job.ts:13-24) —actorId: 'scheduler'. - Job appears in the shared job list with source badge "Scheduled".
- User polls/opens as J1 — no difference in contract
(
reports.service.ts:52-83). (forward-looking): notification on completion viaReportGeneratedevent (04-Modules/Reports.md:33) — no queue mapping today (event-queue-map.tshas no report route).
Journey map
flowchart LR
A[Catalog] --> B[Generate form]
B -->|POST generate| C[Job detail queued]
C --> D{GET jobId poll 2s}
D -->|processing| D
D -->|completed| E[Result view]
D -->|failed| F[Error + retry]
E --> G[Download (planned)]
F --> B
Common failure paths
| Path | Server behavior | Client UX |
|---|---|---|
Invalid type | 400 (enum validation, generate-report.dto.ts:6-8) | inline field error |
Unknown jobId | 404 Report job not found. (reports.service.ts:48) | "not found" state, back to list |
| Worker crash mid-job | job stays processing (no timeout/requeue in code) | stale spinner; QA item in 14 |
| Duplicate submit | new job per POST (no idempotency key) | disable submit while in flight |
04 — Information Architecture (Reports Module)
- 1. Module subtree
- 2. Core objects
- 3. State model per object
- 4. Navigation rules
- 5. Module boundaries (what Reports does NOT own)
How report content is organized in the app. Global structure per 00-shared/05; this file defines the Reports subtree, its objects and states.
1. Module subtree
Reports (module root)
├── Catalog → report type templates (static list, client-defined)
├── Generate → form for one selected template
├── Job list → my tenant's recent jobs (server: `report_jobs` collection)
├── Job detail → status + progress + result of one job
└── Result view → rendered result data / file open `(planned)`
Routes (Flutter, (recommended) — see 15):
/reports, /reports/new, /reports/jobs, /reports/jobs/:jobId,
/reports/jobs/:jobId/result.
2. Core objects
Report type (template)
Static client catalog mirroring the server enum (report-job.schema.ts:7-11):
| Type | Label | Params | Result shape |
|---|---|---|---|
report_card | Report card | studentId, examId (both required by service — reports.service.ts:91) | {studentName, studentId, examId, generatedAt, subjects[], totalMarksObtained, totalMaximumMarks, percentage, grade} (reports.service.ts:96-107) |
attendance_summary | Attendance summary | classId?, startDate?, endDate? (generate-report.dto.ts:17-33) | {total, summary:{status→count}, classId, period:{startDate,endDate}} (reports.service.ts:131-136) |
fee_summary | Fee summary | none (reports.service.ts:140) | {totalInvoices, totalCollected, totalPending, totalOverdue} (reports.service.ts:154-158) |
(planned) types from docs/IMPLEMENTATION_PLAN.md:646: BATCH_PERFORMANCE,
TEST_SERIES_ANALYSIS, DPP_COMPLETION (coaching).
Report job
Server document (report-job.schema.ts:21-43), collection report_jobs:
| Field | Type | Notes |
|---|---|---|
type | enum | required (:22-24) |
status | enum | default queued (:25-30) |
params | object | echo of request (:32-33) |
result | object | set on completion (:35-36) |
error | string | set on failure (:38-39) |
completedAt | date | set on completion (:41-42) |
| + BaseSchema | tenantId, createdAt, updatedAt, version, … (base.schema.ts) |
Indexes: {tenantId, status} and {tenantId, createdAt:-1}
(report-job.schema.ts:47-48) → job list is sorted by recency.
3. State model per object
ReportType: static
ReportJob: queued | processing | completed | failed (server enum, :13-18)
Result: pending | ready | missing (result absent even when completed — possible
for zero-record summaries; treat as ready-empty)
File: not yet | attaching (planned) | streaming (planned) | done | failed
4. Navigation rules
- Job detail is the single hub — reached from job list, from post-submit
redirect, and (future) from notification deep link
(forward-looking). - Result view opens from job detail only when
status == completed. - Catalog → Generate is the only forward path that creates a job.
- No cross-module entry today; students module "report card" shortcut
(forward-looking)— prefill comes from student profile context.
5. Module boundaries (what Reports does NOT own)
- Files/attachment storage →
filesmodule (GET /files/:id/download,files.controller.ts:55-64) once PDF export lands(planned). - Scheduler definitions →
schedulermodule (AttendanceReportJob,attendance-report.job.ts:4-7). - Notifications →
ReportGeneratedevent(planned); queue map has no report route today (event-queue-map.ts). - Data sources (students/attendance/fees/results) — read-only, injected via
modules (
reports.module.ts:20-23).
05 — Screen Inventory (Reports Module)
- 1. Report Catalog (
/reports) - 2. Generate Report Form (
/reports/new) - 3. Job List (
/reports/jobs) - 4. Job Detail / Progress (
/reports/jobs/:jobId) - 5. Result View / Download (
/reports/jobs/:jobId/result) - 6. Scheduled Reports entry (
/reports/scheduled)(planned) - Shared components used
- Analytics events (proposed)
- Keyboard / adaptive
Every screen of the Reports module: intent, route, composition, states, permissions, behavior. Authoritative shared components in 00-shared/03; this file enumerates which each screen uses with module specifics. Format mirrors
design-docs/auth/05_Screen_Inventory.md. Analytics ={module}.{screen}.{action}(proposed).
1. Report Catalog (/reports)
| Field | Detail |
|---|---|
| Purpose | Pick a report type to generate; see what exists |
| Entry | nav "Reports"; dashboard quick action |
| Exit | → /reports/new (select template) |
| Content | Static list of 3 templates (report card / attendance summary / fee summary) with description + required-param hints; (planned) rows for coaching types (BATCH_PERFORMANCE, TEST_SERIES_ANALYSIS, DPP_COMPLETION — IMPLEMENTATION_PLAN.md:646) |
| Permission | report.read (permissions.constants.ts:40) — hide module without it (forward-looking) |
| Widgets | AppListTile per template, AppBadge "Scheduled" on summary types, FAB? none — list only |
| States | idle; loading skeleton; empty (never: list is static) |
| Analytics | reports.catalog.open, reports.catalog.select(type) (proposed) |
2. Generate Report Form (/reports/new)
| Field | Detail |
|---|---|
| Purpose | Enter params for one template, POST generate |
| Entry | catalog select |
| Exit | submit → /reports/jobs/:jobId; back → catalog |
| Form | type (fixed, from selection), conditional params per 08 |
| Primary CTA | Generate report (disabled while in flight) |
| States | validation (client-side presence for report_card!), submitting (button spinner), error 400 inline, 429 countdown, offline banner |
| Permission | report.generate (permissions.constants.ts:39) — submit blocked without it (forward-looking) |
| Analytics | reports.generate.submit(type), reports.generate.success(jobId), reports.generate.failure(code) (proposed) |
3. Job List (/reports/jobs)
| Field | Detail |
|---|---|
| Purpose | Recent report jobs for the tenant, newest first |
| Source | No list endpoint today — client gap. Server has {tenantId, createdAt:-1} index (report-job.schema.ts:48) but no GET /reports route; client keeps a local history of submitted jobIds (limit ~50, persisted) (forward-looking): add GET /reports?status= server-side |
| Row | type label, status chip (queued/processing/completed/failed — report-job.schema.ts:13-18), createdAt, source badge (User/Scheduled) |
| Actions | tap → job detail; swipe/pull-to-refresh re-polls rows in view |
| Empty | "No reports yet — create one" + CTA to catalog |
| Widgets | AppListTile, AppBadge (status), AppSkeleton, RefreshIndicator, AppEmptyState |
4. Job Detail / Progress (/reports/jobs/:jobId)
| Field | Detail |
|---|---|
| Purpose | Live status + result of one job; the polling hub |
| Source | GET /reports/:jobId (reports.controller.ts:20-24) |
| Content | header (type, jobId short, createdAt), status area, param summary chips, result section |
| Status areas | queued: "Waiting in queue…" icon; processing: progress indicator (indeterminate — no % from server); completed: result card; failed: error message + Retry |
| Poll | auto-poll 2 s while queued/processing (see 10); stop on terminal |
| Permission | report.read (forward-looking) |
| Errors | 404 → "Report not found" state + back; network → banner, poll paused, manual retry |
| Analytics | reports.job.status(jobId,status), reports.job.retry(jobId) (proposed) |
5. Result View / Download (/reports/jobs/:jobId/result)
| Field | Detail |
|---|---|
| Purpose | Render completed result; export/share (planned) |
| Entry | job detail when completed |
| Result renderers | report card: subject table + grade badge; attendance: status count cards + totals; fee: 4 metric cards (reports.service.ts:154-158) |
| CTAs | "Download CSV" / "Share PDF" (planned) — no export endpoint in code; "New report from these params" (re-POST) |
| Permission | report.read |
| Empty | completed with empty result → empty state (e.g. zero attendance records) |
| Analytics | reports.result.open(jobId), reports.result.download(jobId) (proposed) |
6. Scheduled Reports entry (/reports/scheduled) (planned)
- Server side exists: scheduler enqueues
generate-attendance-report(attendance-report.job.ts:15-23),actorId: 'scheduler'. Admin UI to schedule/configure is(planned); today jobs just appear via job list (no UI today — see screen 3 gap).
Shared components used
AppListTile, AppBadge, AppButton, AppSkeleton, AppEmptyState,
AppSnackbar, AppBottomSheet (filter), AppOfflineBanner, AppChips (params),
AppMetricCard (module: fee summary), AppProgressRow (module: job status).
Analytics events (proposed)
reports.catalog.{open,select}, reports.generate.{submit,success,failure},
reports.job.{status,retry}, reports.result.{open,download}.
Keyboard / adaptive
- Forms: portrait-first, keyboard-avoid; tablet two-column (form | live job preview).
- Job detail: tablet master-detail with result inline; desktop max-width card.
06 — Screen Specifications (Reports Module)
- S1 — Report Catalog (
/reports) - S2 — Generate Report Form (
/reports/new) - S3 — Job List (
/reports/jobs) - S4 — Job Detail / Progress (
/reports/jobs/:jobId) - S5 — Result View (
/reports/jobs/:jobId/result) - S6 — Scheduled Reports Admin
(planned)
Detailed, per-state specifications for the five screens. Wire contracts in 12_API_Mapping.md; shared components in 00-shared/03; tokens in 00-shared/02 (M3, proposed). All server quotes from
src/modules/reports/**unless noted.
S1 — Report Catalog (/reports)
Purpose
Pick a report template. Static content derived from the server enum
(report-job.schema.ts:7-11).
Layout (mobile → tablet)
- AppBar: "Reports" (title), optional filter menu
(forward-looking). - List (AppListTile × 3):
- Report card — "Per-student marksheet with grades (A+…D)". Badge: needs student + exam.
- Attendance summary — "Counts per status for a class/period". Badge: can be scheduled daily/weekly.
- Fee summary — "Collections: paid / pending / overdue". Badge: no params.
- Below list: muted row "Result delivery: PDF/CSV coming soon"
(planned).
Data
Static table (no API). Sources: report-job.schema.ts:7-11,
generate-report.dto.ts:6-33, reports.service.ts:85-160.
States
| State | Render |
|---|---|
| idle | 3 tiles |
| permission-gap | module hidden (no report.read) (forward-looking) |
| offline | AppOfflineBanner; tiles still render (static) |
Permissions / events
- View:
report.read(permissions.constants.ts:40). reports.catalog.select(type)(proposed).
Accessibility / motion
- Tiles: min 48 dp, semantics label per tile;
m-fastpress ripple; page enterm-basefade-slide (00-shared/08).
S2 — Generate Report Form (/reports/new)
Purpose
Collect params per 08_Form_Specifications.md; POST and hand off to job detail.
Layout
- AppBar: "New report" + back.
- Fixed select (non-editable): report type from catalog selection.
- Param fields per type (08):
report_card: student picker + exam picker (both required by service —reports.service.ts:91).attendance_summary: class picker (optional), start date, end date (optional;generate-report.dto.ts:17-33).fee_summary: no fields; info card "No parameters needed".
- Summary row: params → chips (
reports.service.ts:31-33echoesparams). - Primary CTA: "Generate report" full-width
AppButton.
Submission contract
POST /reports/generate body = GenerateReportDto (generate-report.dto.ts:5-34):
{ "type": "attendance_summary", "classId": "…", "startDate": "2026-08-01", "endDate": "2026-08-31" }
Response: {jobId, status: "queued"} (reports.service.ts:43).
States
| State | Render |
|---|---|
| idle | form |
| validating | inline errors (presence for report_card — DTO does NOT enforce presence, only Mongo-Id format: generate-report.dto.ts:11-13,22-23) |
| submitting | button spinner, fields disabled (prevents duplicate job — server has no idempotency key) |
| success | navigate S4 with jobId |
| error 400 | inline per field (invalid type enum, malformed MongoId) |
| error 429 | countdown, no auto-retry (00-shared/10) |
| error 5xx | AppSnackbar + form preserved (re-POST is safe — new job per POST) |
| offline | AppOfflineBanner; submit blocked |
Behavior rules
- Presence validation for
studentId/examIdonreport_cardBEFORE POST — the server fails the job (async), not the request:"studentId and examId required"(reports.service.ts:91). Client must not ship that UX. - No double-submit while in flight.
- Date fields sent as strings (
generate-report.dto.ts:27-33), no format validation server-side — client sends ISOYYYY-MM-DD; server builds Mongo$gte/$lte(reports.service.ts:120-122).
Permissions / analytics
- Submit:
report.generate(permissions.constants.ts:39) — server does not enforce today (reports.controller.ts:9only JWT)(forward-looking). reports.generate.submit|success|failure(proposed).
S3 — Job List (/reports/jobs)
Purpose
Recent jobs, newest first.
Data — known client gap
No GET /reports endpoint exists. Server only has GET /reports/:jobId
(reports.controller.ts:20-24). The {tenantId, createdAt:-1} index
(report-job.schema.ts:48) implies list queries are intended. Client strategy:
- Local persisted history of submitted jobIds (max 50, oldest evicted);
- Poll only rows currently visible;
(forward-looking): addGET /reports?status=queued|processing|completed|failedfor full history + scheduled jobs visibility.
Layout
- AppBar "Report jobs".
- Filter chips
(forward-looking): All / Running / Completed / Failed. - List rows (AppListTile):
- Leading: status icon (queue/gear/check/cross).
- Title: type label.
- Subtitle:
createdAt(relative),jobIdshort. - Trailing: status
AppBadge(queuedgray,processingamber,completedgreen,failedred — enumreport-job.schema.ts:13-18); source badge "Scheduled" when from scheduler (local heuristic(planned)— no actorId stored on job doc).
- Pull-to-refresh: re-poll visible rows.
States
| State | Render |
|---|---|
| loading | AppSkeleton rows |
| empty | AppEmptyState "No reports yet" + CTA → catalog |
| error | banner + retry (poll failures do not clear list) |
Behavior
- Tap row → S4.
- Rows in terminal state are never re-polled in-session.
- Retry action on failed rows: re-POST original
paramsfrom job doc (report-job.schema.ts:33) → new jobId, row prepended.
Permissions / analytics
report.read(forward-looking);reports.job.list(proposed).
S4 — Job Detail / Progress (/reports/jobs/:jobId)
Purpose
Live lifecycle view + result entry. The polling hub (10_Interaction_Specification).
Data
GET /reports/:jobId (reports.controller.ts:20-24) → full job doc
(report-job.schema.ts:21-43):
{
"_id": "…", "tenantId": "…", "type": "fee_summary", "status": "completed",
"params": {"type":"fee_summary"}, "result": {"totalInvoices": 3, "totalCollected": 1500,
"totalPending": 400, "totalOverdue": 100}, "completedAt": "2026-08-03T…",
"createdAt": "…", "updatedAt": "…", "version": 0
}
Layout
- Header card: type label + status badge + createdAt + short jobId.
- Params card:
paramsas chips (empty → "No parameters"). - Status card (status-dependent, see table).
- Result card (completed only).
- Actions: Retry (failed), "New report from these params" (completed),
Download/Share
(planned).
Status-dependent UI
| Status | UI | Polling |
|---|---|---|
queued | "Waiting in queue" + indeterminate progress row | every 2 s |
processing | "Generating…" + indeterminate progress row + elapsed timer | every 2 s |
completed | success icon; result card; CTAs | stop |
failed | error icon; error text from error field (report-job.schema.ts:39); Retry | stop |
Notes:
- No progress % — server has none; indeterminate only.
- Elapsed timer computed client-side from
createdAt. - Terminal state is sticky: no re-poll, cached doc until screen exit.
States
| State | Render |
|---|---|
| loading | full skeleton |
| 404 | "Report not found" empty state + back (reports.service.ts:48) |
| network fail | banner; polling paused; manual "Retry check" |
| offline | banner + last-good doc |
| cross-tenant 404 | same "not found" (server scopes by tenant via BaseRepository — no leak, base.repository.ts:21-23) |
Permissions / analytics
report.read(forward-looking).reports.job.status(jobId,status),reports.job.retry(jobId)(proposed).
S5 — Result View (/reports/jobs/:jobId/result)
Purpose
Render completed result per type; export (planned).
Result renderers (from reports.service.ts:96-160)
Report card (reports.service.ts:96-107):
- Header:
studentName(⚠ currently admission number,:98),examId,generatedAt. - Subject table: per row
subjects[](name, marks, max, grade — shape fromresult.service.ts:68-91). - Footer metrics:
totalMarksObtained/totalMaximumMarks,percentage,gradebadge (bands A+…D,result.service.ts:130-136).
Attendance summary (reports.service.ts:131-136):
- Metric cards:
total+ per-status counts (summarykeyed by status — present/late/absent/etc. as stored by attendance module). - Period line:
classId,period.startDate/endDate(nulls → "All time").
Fee summary (reports.service.ts:154-158):
- 4 metric cards:
totalInvoices,totalCollected,totalPending,totalOverdue(numbers frominvoiceRepo.find({})+ paid/due math:142-152). - Derived: collection rate = collected ÷ totalInvoices
(client-derived).
States
| State | Render |
|---|---|
| ready | renderer above |
| completed but result missing | empty state (e.g. zero attendance) — result may be {} (zero-record summaries still complete, :131-136) |
| not completed | guard: redirect to S4 (result only reachable when status == completed) |
| offline | last-good cached result + banner |
Export (planned)
- "Download CSV"/"Share PDF" — NO endpoint today. Blueprint:
GET /reports/:jobId/download(04-Modules/Reports.md:26), streamed (:49), files undersl/{tenantId}/reports/{uuid}TTL (:50). - Until then: hide export CTAs or show "coming soon" tooltip.
Permissions / analytics
report.read;reports.result.open|download(proposed).
S6 — Scheduled Reports Admin (planned)
- Scheduler enqueues attendance reports daily/weekly today
(
attendance-report.job.ts:13-24), but there is no UI to configure. - Future screen: schedule list + CRUD (perm
scheduler.read/create/delete,permissions.constants.ts:94-96); result surfaced through job list.
07 — Component Library (Reports Module)
- C1 —
ReportTypeTile(S1 catalog row) - C2 —
ReportStatusBadge(S3/S4) - C3 —
JobProgressCard(S4 status card) - C4 —
JobParamChips(S4 params card) - C5 —
MetricCard(S5 result metrics) - C6 —
ReportCardResult(S5 report card) - C7 —
JobListRow(S3) - C8 —
PollingRetryButton(S4) - Shared components consumed
- Rules
Module-specific widgets on top of the shared library (00-shared/03). Reused shared components are referenced, not redefined. All widgets: Material 3, tokens 00-shared/02 (proposed),
m-*motion 00-shared/08.
C1 — ReportTypeTile (S1 catalog row)
- Extends
AppListTile. Props:label,description,badgeText?,requiredParams: String[],onTap. - Semantics: "Generate
- Data mapping: static table 04 §2; enum source
report-job.schema.ts:7-11.
C2 — ReportStatusBadge (S3/S4)
- Wraps
AppBadge; mapsReportJobStatus(report-job.schema.ts:13-18) → label/color/icon:
| Status | Label | Color | Icon |
|---|---|---|---|
queued | Queued | neutral | schedule |
processing | Processing | amber | hourglass |
completed | Completed | green | check_circle |
failed | Failed | red | error |
- Semantics:
<status> <type>live-region on change (S4).
C3 — JobProgressCard (S4 status card)
- Indeterminate
LinearProgressIndicator+ status copy + optional elapsed timer (client-side fromcreatedAt). - Props:
status,createdAt,error?. - No % bar — server exposes no progress; do NOT fake one.
- Failed variant: error icon +
errortext (report-job.schema.ts:39) + RetryAppButton.
C4 — JobParamChips (S4 params card)
- Renders
params(report-job.schema.ts:33) asAppChips; empty → muted "No parameters". Keys: type, studentId, classId, examId, startDate, endDate (generate-report.dto.ts:6-33).
C5 — MetricCard (S5 result metrics)
- Number + label card. Used by fee summary (
totalInvoices,totalCollected,totalPending,totalOverdue—reports.service.ts:154-158) and attendance (total+ per-status counts —reports.service.ts:131-136). - Currency variant for money values; integer variant otherwise.
MetricCardGridwrapper (2 cols mobile, 4 tablet).
C6 — ReportCardResult (S5 report card)
- Header (studentName — note admission number today,
reports.service.ts:98, examId, generatedAt), subject table (subjects[]fromresult.service.ts:68-91), footer metrics +gradebadge (bandsresult.service.ts:130-136). - Long subject lists → scrollable table (landscape hint).
C7 — JobListRow (S3)
- Extends
AppListTile: status leading icon, type label, relative createdAt,ReportStatusBadgetrailing, optional "Scheduled" chip. - Pull-to-refresh via
RefreshIndicator(re-poll visible rows).
C8 — PollingRetryButton (S4)
- Manual "Retry check" shown when a poll attempt fails (network); resumes the auto-poll cycle (10).
Shared components consumed
AppListTile, AppBadge, AppButton, AppChips, AppSkeleton,
AppEmptyState, AppSnackbar, AppOfflineBanner, AppBottomSheet,
AppTextField, AppDatePicker (00-shared/03; pickers for S2).
Rules
- No widget may hardcode a status/type string — always map from the server enums
so future types (
IMPLEMENTATION_PLAN.md:646) degrade to a generic row. - Fallback: unknown type label → raw enum string.
08 — Form Specifications (Reports Module)
- 1. Generate report — field table
- 2. Conditional visibility
- 3. Client-side validation rules
- 4. Submit payloads (examples)
- 5. Server-side error mapping
- 6. Accessibility / behavior
The single form: Generate Report (S2). Fields mirror
GenerateReportDto(generate-report.dto.ts:5-34). Field-level contract, validation, and conditional visibility per report type.
1. Generate report — field table
Server DTO: generate-report.dto.ts:5-34. All params @IsOptional; only
type is required and enum-validated (:6-8).
| Field | Control | Type | Required (DTO) | Required (service) | Notes |
|---|---|---|---|---|---|
type | fixed selector (from catalog) | enum | ✅ | — | 3 values (report-job.schema.ts:7-11) |
studentId | student picker | MongoId | ❌ | ✅ for report_card | reports.service.ts:91 — client must require |
classId | class picker | MongoId | ❌ | ❌ | attendance filter reports.service.ts:118 |
examId | exam picker | MongoId | ❌ | ✅ for report_card | reports.service.ts:91 |
startDate | date picker | string | ❌ | ❌ | $gte filter reports.service.ts:120-121 |
endDate | date picker | string | ❌ | ❌ | $lte filter reports.service.ts:122 |
DTO format checks: @IsMongoId on id fields (:11-13,17-19,22-23),
@IsString on dates (:27-33) — no ISO/date-format validation server-side.
2. Conditional visibility
| Type | Visible fields | Hidden |
|---|---|---|
report_card | studentId, examId | classId, dates |
attendance_summary | classId (optional), startDate, endDate (optional) | studentId, examId |
fee_summary | none — info card | all |
Hidden fields must NOT be sent (server echoes params blindly:
reports.service.ts:31-33; sending junk pollutes the job doc).
3. Client-side validation rules
report_card:studentId+examIdrequired before submit — the service fails the job asynchronously otherwise:"studentId and examId required"(reports.service.ts:91). This is the only real required rule.startDate ≤ endDate(client check; server does no comparison —reports.service.ts:119-123builds a range regardless).- Dates serialized ISO
YYYY-MM-DD(server treats as strings,reports.service.ts:120-122). - No duplicate submit while in flight (server has no idempotency key —
each POST = new job,
reports.service.ts:31-43).
4. Submit payloads (examples)
{ "type": "report_card", "studentId": "64f…", "examId": "65a…" }
{ "type": "attendance_summary", "classId": "64e…", "startDate": "2026-08-01" }
{ "type": "fee_summary" }
5. Server-side error mapping
| Failure | Source | Client handling |
|---|---|---|
400 invalid type | DTO @IsEnum (:6-8) | inline, catalog shouldn't produce it |
| 400 malformed id | @IsMongoId (:11-13) | inline field error |
| 429 rate limit | global (00-shared/07) | countdown, no auto-retry |
| 401 expired token | JWT guard (reports.controller.ts:9) | silent refresh → resubmit |
| 5xx | filter (http-exception.filter.ts) | snackbar + form kept (re-POST safe) |
job failed (async) | reports.service.ts:79-82 | surfaced on S4, NOT the form |
6. Accessibility / behavior
- Picker fields: searchable dropdowns; semantics "select
- Dates via
AppDatePicker(00-shared/03), range constraint (2). - Keyboard:
TextInputAction.nextchain; submit on last fielddone(mobile). - Errors: inline + live-region announcement, focus first invalid field.
09 — User Behaviour (Reports Module)
- 1. Behavioural patterns
- 2. Behavioural anti-patterns to design out
- 3. Behavioural metrics (proposed)
Expected behaviour patterns and the server facts that shape them. Behavior analytics
(proposed)throughout; no analytics SDK in repo (00-shared/10 §8).
1. Behavioural patterns
B1 — Submit-and-watch (dominant)
User submits → lands on S4 → watches status go queued → processing →
completed. Average wait: seconds (attendance/fee) to minutes (term-end card
batches). Server fact: no WebSocket topic, no progress % — polling is the
only mechanism (reports.controller.ts:20-24).
Implications:
- Default poll 2 s (10); show elapsed time so the wait feels productive.
- Do not block navigation — user can leave S4; job continues (server-owned).
B2 — Submit-and-come-back
User submits then leaves; returns later via job list. Server fact: job doc
persists with params + result (report-job.schema.ts:33-36) — result is
re-readable indefinitely (no TTL in schema; blueprint TTL cleanup is for files,
04-Modules/Reports.md:50).
Implication: S4 must render a terminal job from a single GET — no re-poll.
B3 — Re-run with tweaks
Completed fee summary → "New report from these params" (S4 CTA) — users
regenerate monthly with same/edited params. Server: cheap, new job per POST
(reports.service.ts:31-43). Form prefills from params (:33).
B4 — Failure → retry
Job failed → user retries. Server facts: error string stored
(report-job.schema.ts:39, reports.service.ts:79-82); retry = new POST;
known async failures: missing student/exam (:91), student not found (:94),
unknown type (:75).
Implication: surface the exact error; if it says "studentId and examId required", send user to the form, not retry.
B5 — Scheduled report consumption
Daily/weekly attendance jobs (attendance-report.job.ts:13-24) appear in job
list. Today there is no list API (S3 gap) and no completion notification
(ReportGenerated unmapped, event-queue-map.ts) — users discover via history
(forward-looking).
2. Behavioural anti-patterns to design out
| Anti-pattern | Design response |
|---|---|
| Double-submit → duplicate jobs | disable CTA while in flight (B1) |
| Staring at an eternal spinner after network loss | banner + pause polling + manual retry (S4) |
Confusing failed with a server error | distinct failed state + error text + Retry vs form CTA |
| Expecting a PDF that doesn't exist | hide export CTAs until (planned) download endpoint lands |
| Filtering a job list that has no API | local history only; label clearly; no fake "all jobs" count |
3. Behavioural metrics (proposed)
| Metric | Event | Target |
|---|---|---|
| Generate → completed seen | reports.job.status terminal | ≥ 90% of polls reach terminal |
| Time-to-complete (per type) | reports.job.status(completed) + createdAt | fee/attendance < 5 s p95 |
| Retry rate | reports.job.retry | < 5% |
| Repeat generation | "new from params" | ≥ 20% monthly of fee summaries |
| Export adoption | reports.result.download | n/a until (planned) |
10 — Interaction Specification (Reports Module)
- 1. The polling engine (core interaction)
- 2. Status transitions (UI)
- 3. Screen-level interactions
- 4. Error taxonomy (client)
- 5. Motion & haptics
- 6. Keyboard / adaptive (S2)
Interaction contracts: polling engine, transitions, error handling, motion. Motion tokens
m-fast/m-basefrom 00-shared/08; shared interaction baseline 00-shared/08.
1. The polling engine (core interaction)
Server contract: GET /reports/:jobId returns the full job doc
(reports.controller.ts:20-24, reports.service.ts:46-50); statuses
queued | processing | completed | failed (report-job.schema.ts:13-18).
Algorithm
start(jobId):
poll() every 2s while status in {queued, processing}
on completed → stop; render result (S5)
on failed → stop; render error + Retry
on 404 → stop; not-found state
on network/5xx → pause cycle; show banner + "Retry check";
manual retry resumes cycle
terminal status is sticky; a fresh screen open with terminal doc does not poll
Parameters
| Aspect | Value | Rationale |
|---|---|---|
| Interval | 2 s | fast enough for UX; harmless load (1 doc query, indexed {tenantId,status}, report-job.schema.ts:47) |
| Max consecutive failures | 2 | then pause; exponential backoff for manual retry (1 s → 2 s → 4 s) |
| Timeout per request | 10 s | global client HTTP timeout (00-shared/07) |
| Max overall wait | none | server has no job TTL; user can leave and return (B2) |
Jitter & life
- Poll times: 2 s ± 400 ms jitter (thundering-herd avoidance at term end when a school generates N cards).
- Poll cycle lifecycle: starts on S4
initState; cancelled on dispose; aTimer(not a loop) so cancellation is clean.
2. Status transitions (UI)
| From | To | Trigger | Transition |
|---|---|---|---|
| queued | processing | poll | badge swap (C2), progress row gains elapsed timer — m-fast crossfade |
| processing | completed | poll | success reveal; result card slides in m-base; haptic light (mobile) |
| processing | failed | poll | error card + error text; m-fast fade |
| any non-terminal | offline | network fail | banner slide-in; cycle pauses |
| offline | resumed | manual retry | banner clears; cycle resumes |
No transition animation for the badge itself beyond color change — keep perceptual noise low at 2 s cadence.
3. Screen-level interactions
S2 Generate form
- Submit → button
loading; on success: navigate pushReplacement to S4 (form is dead after submission; back returns to catalog). - Failure: inline errors, form intact.
report_cardwithout required ids: inline error BEFORE network (08 §3.1).
S3 Job list
- Pull-to-refresh: re-poll visible rows (only non-terminal ones).
- Row tap → S4. Failed row menu: Retry (re-POST
params→ new jobId,reports.service.ts:31-43). - Filter chips (All/Running/Completed/Failed) filter locally — no query API.
S4 Job detail
- Auto-poll (1). Elapsed timer ticks 1 s while processing.
- Retry (failed): confirm dialog → new POST → pushReplacement to new job detail.
- "New report from these params": prefill S2 from
params(:33).
S5 Result
- Scrollable content; export CTAs disabled w/ tooltip "coming soon"
(planned). - Share sheet
(forward-looking)once PDF exists.
4. Error taxonomy (client)
| Error | Detection | UX |
|---|---|---|
| 404 | poll GET | not-found state; back to list |
| 401 | interceptor | silent refresh, resume poll |
| 429 | interceptor | pause poll 30 s; countdown snackbar; no auto-retry (00-shared/10) |
| network | timeout/connection | banner + pause (1) |
| job failed | poll GET status | terminal UI (2) |
5. Motion & haptics
- Status badge changes:
m-fast(150 ms) color/opacity crossfade. - Result reveal:
m-base(250 ms) slide-up + fade. - Screen push: shared push transition (
00-shared/08). - Haptics:
HapticFeedback.mediumImpacton completed reveal; light on failed.
6. Keyboard / adaptive (S2)
resizeToAvoidBottomInset; next-chain; tablet: form left, live job preview right (05 §Keyboard).
11 — Design System Mapping (Reports Module)
- 1. Screens → design tokens
- 2. Component mapping
- 3. Icons
- 4. Typography & density
- 5. Dark mode
- 6. Accessibility mapping (00-shared/09)
- 7. Deviations & notes
Maps every Reports surface to the shared design system. Tokens: 00-shared/02 (Material 3, proposed defaults); components: 00-shared/03; motion: 00-shared/08; a11y: 00-shared/09. Module components 07_Component_Library.md.
1. Screens → design tokens
| Surface | Color | Type scale | Spacing | Shape |
|---|---|---|---|---|
| S1 catalog | surface bg; primary icons | titleLarge (list titles), bodyMedium (descriptions) | list item 16-8-16; section gap 24 | shapeMedium tiles |
| S2 form | surface; primary CTA; error inline | headlineSmall (title), bodyLarge (fields), labelMedium (chips) | field gap 16; bottom padding 24 | shapeMedium inputs, shapeSmall chips |
| S3 job list | surface; status colors (C2) | titleMedium rows, bodySmall meta | row 12; chip gap 8 | shapeSmall badges |
| S4 detail | surfaceContainerLow cards on surface | headlineSmall type label, bodyMedium meta | card padding 16; card gap 12 | shapeLarge status card |
| S5 result | surface; secondaryContainer metric cards; error failed | displaySmall metrics (fee), titleMedium labels, bodyMedium table | metric grid gap 12; table cell 8 | shapeMedium cards; grade badge shapeSmall |
| Status palette | queued: surfaceVariant; processing: tertiary; completed: primary; failed: error | — | — | — |
2. Component mapping
| Screen | Shared (00-shared/03) | Module (07) |
|---|---|---|
| S1 | AppListTile, AppBadge | ReportTypeTile |
| S2 | AppTextField, AppDatePicker, AppButton, AppSnackbar, AppChips | (none) |
| S3 | AppListTile, AppSkeleton, AppEmptyState, RefreshIndicator, AppMenu | ReportStatusBadge, JobListRow |
| S4 | AppCard, AppButton, AppSnackbar, AppOfflineBanner | JobProgressCard, JobParamChips, PollingRetryButton |
| S5 | AppCard | MetricCard/Grid, ReportCardResult |
3. Icons
schedule (queued), hourglass_top (processing), check_circle (completed),
error_circle (failed) — outlined set; 20 dp grid, onSurfaceVariant.
4. Typography & density
- Numbers (fee metrics) use tabular figures (
fontFeatures: tabularFigures) — alignment-critical. - Mobile density 4, tablet 0 (00-shared/02).
5. Dark mode
- All tokens dark-adaptive; status palette keeps hue, lightens
on-colors. - Indeterminate progress:
tertiaryContainertrack.
6. Accessibility mapping (00-shared/09)
| Surface | Requirement |
|---|---|
| all | 4.5:1 text contrast; touch targets ≥ 48 dp |
| S4 | status change announced via live region (badge swap must not re-announce whole card) |
| S5 | result table: proper Semantics header mapping; grade badge label includes band |
| S2 | errors linked to fields (errorText semantics) |
| all | ReduceMotion: poll unaffected; replace slide/fade with instant swap |
7. Deviations & notes
- No brand illustration; report icons are the only imagery — matches shared baseline.
- Progress: indeterminate only (server constraint) — do not restyle into a fake percentage (07 C3).
12 — API Mapping (Reports Module)
- 0. Module-wide request envelope & client policy
- E1 — Generate a report
- E2 — Get job status and result (polling contract)
- E3 — Download result
(planned) - E4 — List jobs (needed by S3)
- E5 — Files (delivery path for future exports)
(planned) - E6 — Scheduler (scheduled reports)
- Worker contract (server-side context)
- Client-side error mapping table
- Optimistic / undo
Exact wire contract for every screen → endpoint. Base
/api/v1; envelope per 00-shared/07 andresponse-envelope.interceptor.ts/http-exception.filter.ts. Endpoints fromreports.controller.ts; business rules fromreports.service.ts,report-job.repository.ts,report-job.schema.ts. Controller guard chain:JwtAuthGuardonly (reports.controller.ts:9).
0. Module-wide request envelope & client policy
| Aspect | Contract |
|---|---|
| Base | https://api.<domain>/api/v1 |
| Headers | Authorization: Bearer <accessToken>; x-request-id; Content-Type: application/json |
| Success | {success:true, message:"OK", data, timestamp, requestId} (response-envelope.interceptor.ts:48-53) |
| Error | {success:false, message, error:{code,details?}, timestamp, requestId} (http-exception.filter.ts:27-35) |
| Tenancy | tenantId never in body — from JWT via TenantContextService; repository tenant-scoped (base.repository.ts:21-23); worker restores tenant context (report.worker.ts:20-29) |
| RBAC | ⚠ Not enforced on reports endpoints. report.generate / report.read exist (permissions.constants.ts:39-40) but the controller carries only @UseGuards(JwtAuthGuard) (reports.controller.ts:9) and no @Permissions decorator. Client must hide/disable by permission list until server enforces (OQ — see 14 §9). |
| Polling | No server push; client polls GET /reports/:jobId per 10 §1 |
E1 — Generate a report
| Endpoint | POST /reports/generate (reports.controller.ts:14-18) |
| Request | GenerateReportDto (generate-report.dto.ts:5-34): type (req, enum report_card|attendance_summary|fee_summary — report-job.schema.ts:7-11), studentId?/classId?/examId? (@IsMongoId), startDate?/endDate? (@IsString) |
| Success | 201 (Nest default — blueprint says 202, 04-Modules/Reports.md:48; code returns the default, gap) data: {jobId, status:"queued"} (reports.service.ts:43) |
| Semantics | Creates report_jobs doc (:31-34), enqueues BullMQ job generate on report-generate queue with {jobId, tenantId, type, params} (:36-41). No idempotency key — each POST = new job |
| Errors | 400 (enum/mongo-id validation); 401; 429; 5xx (queue down) |
| Screen | S2 (06 §S2) |
E2 — Get job status and result (polling contract)
| Endpoint | GET /reports/:jobId (reports.controller.ts:20-24) |
| Success | 200 data: ReportJobDocument — full doc (report-job.schema.ts:21-43) incl. type, status, params, result?, error?, completedAt?, createdAt |
| 404 | RESOURCE_NOT_FOUND, Report job not found. (reports.service.ts:48) — tenant-scoped; cross-tenant id also 404s |
| Polling | Client polls every 2 s while status ∈ {queued, processing}; terminal statuses completed/failed stop the cycle (10 §1); 404 → stop |
| Screen | S4/S5 (06 §S4-S5) |
E3 — Download result (planned)
| Endpoint | GET /reports/:jobId/download — blueprint only (04-Modules/Reports.md:26) |
| Status | NOT in code. No controller route, no PDF/CSV serializer, no file write. Blueprint intent: streamed downloads (:49), files under sl/{tenantId}/reports/{uuid} with TTL cleanup (:50) |
| Interim | Until it lands: results are JSON on the job doc (E2); export CTAs hidden (06 §S5) |
E4 — List jobs (needed by S3)
| Endpoint | None. Index {tenantId, createdAt:-1} exists (report-job.schema.ts:48) but no GET /reports route |
| Client | local persisted history of submitted jobIds; (forward-looking) server GET /reports?status= |
E5 — Files (delivery path for future exports) (planned)
| Upload | POST /files/upload (multipart, file.upload — files.controller.ts:29-41) |
| Read/download | GET /files/:id (file.read, :49-53); GET /files/:id/download (file.read, :55-64) → Content-Type + Content-Disposition: attachment; filename="…" streamed buffer (files.service.ts:58-64) |
| Delete | DELETE /files/:id (file.delete, :66-71) — soft delete |
| Note | Report generation does NOT write files today; E3 will need this path |
E6 — Scheduler (scheduled reports)
| Endpoint | none (admin API (planned), perms scheduler.read/create/delete, permissions.constants.ts:94-96) |
| Behavior | AttendanceReportJob.execute enqueues generate-attendance-report on the report-generate queue (attendance-report.job.ts:13-24); scheduler service wires queue QUEUE.REPORT_GENERATE (scheduler.service.ts:32,102,163,219) |
| Payload | {eventType:"attendance-report", tenantId, correlationId, actorId:"scheduler", payload:{reportType:"daily"|"weekly"}} (attendance-report.job.ts:16-21) |
| Note | Enqueued jobs use jobName generate-attendance-report; ReportsService.executeJob is reached via the worker (report.worker.ts:34) and switches on job doc type (reports.service.ts:64-76) — scheduled jobs must land as attendance_summary jobs to be processed |
Worker contract (server-side context)
- Queue:
QUEUE.REPORT_GENERATE = 'report-generate'(queue.constants.ts:10). - Processor:
@Processor('report-generate')(report.worker.ts:7);process()restores tenant context thenreportsService.executeJob(jobId)(:18-35). executeJob: markProcessing → type switch (report_card/attendance_summary/fee_summary—reports.service.ts:64-76) → markCompleted w/ result, or markFailed w/ error (:78-82; repository:21-47).eventQueueMaphas NO report route —ReportGeneratednotifications(planned)(event-queue-map.ts).
Client-side error mapping table
| Screen | code | UI |
|---|---|---|
| S2 submit | 400 | inline field error |
| S2 submit | 401 | silent refresh → resubmit |
| S2 submit | 429 | countdown, no auto-retry |
| S2 submit | 5xx | snackbar + form kept (re-POST safe — new job per POST) |
| S4 poll | 404 | not-found state, back |
| S4 poll | 429 | pause poll 30 s |
| S4 poll | 5xx/network | banner + manual retry (10 §1) |
| any (future) | 403 | shared 403 screen — server emits none today (no @Permissions) |
Optimistic / undo
- Generate: no optimistic result (async); navigate on 201 only.
- Retry: new job; previous failed job remains in history (audit trail).
- No undo anywhere — jobs are immutable once created.
13 — State Management (Reports Module)
- 1. Cubits
- 2. Job state machine (server-authoritative)
- 3. Data persistence
- 4. Loading / streaming / realtime
Cubit/BloC layout for the Reports module per 00-shared/06 (recommendation, not an implemented decision). One Cubit per screen cluster; polling owned by a single engine. Includes the job state machine.
1. Cubits
ReportCatalogCubit (S1)
- State:
CatalogState { templates: ReportTemplate[] }— static; loads once;(forward-looking)fetches server-supported types. - No async beyond init.
GenerateReportCubit (S2)
- State:
GenerateState { type, params, submitting, fieldErrors }. - Events:
SelectType,UpdateParam,Submit. Submit: validate (client presence rule 08 §3.1) →POST /reports/generate→ success: emitjobCreated(jobId)→ router to S4 (reports.controller.ts:14-18).- Server 400 maps to
fieldErrors; 429/5xx to snackbar (12 §client table).
ReportJobListCubit (S3)
- State:
JobListState { jobs: LocalJobEntry[], filter, loading }. LocalJobEntry={jobId, type, status, createdAt, params?, source}from the local history store (12 E4 gap) — refreshed by polling visible rows.- Events:
LoadHistory,Refresh,Filter,Retry(jobId).
ReportJobDetailCubit (S4) — owns the polling engine
- State:
JobDetailState { job?, status, elapsed, pollPaused, error? }. - Events:
Open(jobId),PollTick,Retry,CheckAgain(manual after pause). - Polling engine (10 §1):
Timer.periodic(2s)while status non-terminal; GET → map doc; terminal → cancel timer; network/5xx → pause + emitpollPaused; 404 → terminal not-found. Retry: re-POSTparams(reports.service.ts:31-43) → pushReplacement detail of new job.
ReportResultCubit (S5)
- State:
ResultState { job, renderModel, exporting }. - Pure mapping from
job.resultper type (06 §S5 renderers,reports.service.ts:96-160). Export(planned): no-op/disabled today; future: E3 download → share sheet.
2. Job state machine (server-authoritative)
Statuses from report-job.schema.ts:13-18; transitions applied by
report-job.repository.ts:21-47 (markProcessing/markCompleted/markFailed) and
the initial queued default (report-job.schema.ts:28-29). The Cubit mirrors,
never predicts.
stateDiagram-v2
[*] --> queued: job created (POST /reports/generate)\nreports.service.ts:31-43
queued --> processing: worker executes\n(reports.service.ts:59, markProcessing)
processing --> completed: result stored\n(markCompleted, repo :27-38)
processing --> failed: error stored\n(markFailed, repo :40-47)
queued --> failed: worker error path\n(reports.service.ts:79-82)
failed --> queued: user Retry = NEW job POST\n(new jobId, old stays failed)
completed --> [*]: result readable via GET\n(reports.service.ts:46-50)
failed --> [*]
Notes:
queued → failedwithoutprocessingis possible (executor throws before markProcessing completes —reports.service.ts:59-82); UI must not assume processing was seen.- No
processing → queuedrequeue exists in code — a crashed worker leaves a job stuck inprocessing(QA item,14 §5). - Retry is never a status transition on the same doc — always a new job.
3. Data persistence
| Store | Key | TTL | Purpose |
|---|---|---|---|
| local history | reports.localHistory | cap 50 entries | S3 list (E4 gap) |
| last-good job doc | reports.job.{jobId} | 24 h | S4 offline render |
| last-good result | reports.result.{jobId} | 24 h | S5 offline render |
Server remains source of truth; caches are read-only mirrors.
4. Loading / streaming / realtime
| Screen | Loading | Streaming | Realtime |
|---|---|---|---|
| S1 | none (static) | — | — |
| S2 | submit spinner | — | — |
| S3 | skeleton rows | — | poll visible rows on refresh |
| S4 | full skeleton | — | poll 2 s (the module's realtime) |
| S5 | render on doc | — | — (terminal) |
No WS topic for reports (00-shared/07 §8 has none) — (forward-looking):
push notification on ReportGenerated (04-Modules/Reports.md:33).
14 — QA Checklist (Reports Module)
- 1. Functional (client)
- 2. Large datasets
- 3. Concurrency
- 4. Retry & DLQ
- 5. Reliability / failure injection (server)
- 6. Scheduled reports
- 7. Permissions & security
- 8. Performance budgets
- 9. Accessibility (00-shared/09)
- 10. Release gates
Test plan for the async generate → poll → view flow. Baseline: 00-shared/10. Module facts that drive cases: job doc schema (
report-job.schema.ts:21-43), service logic (reports.service.ts:28-160), worker (report.worker.ts:18-35), repository (report-job.repository.ts:21-47), queue namereport-generate(queue.constants.ts:10).
1. Functional (client)
-
POST generate returns
{jobId, status:"queued"}(reports.service.ts:43); navigates to S4. - S4 shows all 4 statuses via poll; terminal stops polling (10 §1).
-
Report card requires student+exam client-side (08 §3.1); server async
failure path verified too (
reports.service.ts:91). -
Attendance summary with classId/date range filters correctly
(
reports.service.ts:117-123); result counts per status match source records (:126-129). -
Fee summary math: collected/pending/overdue per
reports.service.ts:142-152(OVERDUE status wins —:150). -
Empty dataset → completed with
total:0/empty summary renders empty state, not error (reports.service.ts:131-136). -
404 on poll → not-found state; cross-tenant jobId → same 404 (no leak,
base.repository.ts:21-23). - Retry creates a NEW job; failed job stays in history.
- Local history (S3) survives app restart; cap 50 (13 §3).
2. Large datasets
-
report_card with max subject count (schema-driven) renders table without
jank;
tabularFigureson marks (11 §4). -
attendance_summary over a full academic year: server loops all matching
records in memory (
reports.service.ts:125-129) — verify response time and that the client handles a largesummarymap / long period render. -
fee_summary with >10k invoices: loop
:147-152is O(n) in-process — measure p95; QA flag: blueprint requires streaming for large exports (04-Modules/Reports.md:49), code does not stream yet. - 50+ jobs in local history list: lazy list builds, no frame drops.
3. Concurrency
- Double-tap submit → single job (button disabled in flight, 09 §2).
- Two clients polling same job concurrently — no conflict (read-only GET).
-
Generate N jobs at once (term-end) — queue drains; no DB contention;
{tenantId,status}index used (report-job.schema.ts:47). - Same user re-generates same report while previous is processing — independent jobs; results not crossed (jobId-keyed).
4. Retry & DLQ
-
Worker failure (e.g. student not found,
reports.service.ts:94) → jobfailedwith readableerror(:79-82); user sees error + Retry. -
BullMQ retries:
report-generatequeue config (retries/DLQ) verified — worker redeploys mid-job don't lose jobs(verify queue options). -
DLQ behavior: poisoned job (bad payload) lands in DLQ; tenant gets failed
job doc; no silent gap between queue state and
report_jobsstate (13 §2 — two sources of truth must stay consistent).
5. Reliability / failure injection (server)
-
Stuck processing: worker crash between
markProcessingand terminal write leavesprocessingforever (no requeue/timeout in code —report-job.repository.ts:21-47). Decide: watchdog job or timeout → mark failed. Known gap — test and document. -
Redis down at POST → 5xx; form kept; no orphan job doc (create happens
before queue.add —
reports.service.ts:31-41; a queue failure leaves an orphanedqueuedjob doc — verify cleanup behavior). - Mongo down at GET → 5xx; client banner + paused poll (10 §4).
-
Scheduler enqueues while worker off — jobs stay queued (BullMQ durable);
process when worker returns (
scheduler.service.ts:32,163,219). -
Worker tenant isolation: job for tenant A cannot read tenant B data
(
report.worker.ts:20-29).
6. Scheduled reports
-
Daily/weekly attendance job →
report_jobsdoc exists with correct type soexecuteJobswitch handles it (reports.service.ts:64-76;attendance-report.job.ts:13-24). ⚠ verify enqueued payload produces a job doc — the queue job payload{reportType}(attendance-report.job.ts:21) differs fromReportsService.generate's shape; confirm end-to-end. -
Remove-on-complete retention
{age:3600,count:100}(:23) doesn't destroyreport_jobsdocs (different stores — confirm).
7. Permissions & security
-
report.generate/report.readdefined (permissions.constants.ts:39-40). -
Gap: endpoints enforce JWT only (
reports.controller.ts:9) — no@Permissions. Plan: server adds permission decorators; client gates meanwhile (12 §0). -
tenantId never accepted from body (
BaseSchema, repository scoping). -
Error messages don't leak cross-tenant existence (404 path,
:48).
8. Performance budgets
| Metric | Budget |
|---|---|
| POST → 201 | < 500 ms |
| poll GET | < 150 ms p95 (indexed) |
| fee_summary >10k invoices | < 5 s p95 (flag if exceeded) |
| app frame rate during poll | no jank at 2 s cadence |
| poll load | ≤ 1 req/2 s/device (jittered, 10 §1) |
9. Accessibility (00-shared/09)
- Status changes announced (live region) without full-card re-announce.
- 48 dp targets; contrast 4.5:1 (11 §6).
- ReduceMotion: instant swaps, polling unchanged.
10. Release gates
- All of 1–3 pass; 4–5 documented decisions (stuck-processing & orphan-queued cases); 7 server change ticket opened for RBAC enforcement.
15 — Flutter Implementation Guide (Reports Module)
- 1. Packages
- 2. Service layer
- 3. Polling engine (shared widget-level service)
- 4. Cubit wiring (
13 §1) - 5. Download & viewer
(planned) - 6. Scheduler & notifications
(forward-looking) - 7. Offline
- 8. Testing
Concrete Flutter build notes for the Reports module. App architecture per 00-shared/11 (recommended: Cubit, 00-shared/06); state layout 13; polling 10; contracts 12.
1. Packages
- Existing shared stack only — no new dependencies for polling (core
dart:async Timer). - Downloads/viewer
(planned)— see §5 for recommended additions once the server endpoint exists:flutter_downloaderordio+path_provider(follow 00-shared/11). - No PDF viewer today (no PDFs exist).
2. Service layer
class ReportsApi {
final Dio dio; // shared client, `00-shared/11`
Future<JobCreated> generate(GenerateReportDto dto) async {
final r = await dio.post('/reports/generate', data: dto.toJson());
return JobCreated.fromJson(r.data['data']); // {jobId, status}
}
Future<ReportJobDoc> getJob(String jobId) async {
final r = await dio.get('/reports/$jobId');
return ReportJobDoc.fromJson(r.data['data']);
}
}
Envelope unwrap per 00-shared/07; DTO mirrors GenerateReportDto
(generate-report.dto.ts:5-34): type enum string, optional MongoIds as
strings, dates as ISO strings.
3. Polling engine (shared widget-level service)
class JobPoller {
JobPoller(this.api, {this.interval = const Duration(seconds: 2)});
Timer? _t;
void start(String jobId, void Function(ReportJobDoc) onDoc,
void Function(JobPollerError) onError) {
_t ??= Timer.periodic(interval + _jitter(), () async {
try {
final doc = await api.getJob(jobId);
onDoc(doc); // cubit maps status
if (doc.status.isTerminal) stop();
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionTimeout) onError(/* pause */);
}
});
}
void stop() { _t?.cancel(); _t = null; }
}
Rules: 2 s ± 400 ms jitter; pause after 2 consecutive failures; stop() on
terminal/404; cancel in dispose (10 §1). ReportJobStatus maps
queued|processing|completed|failed (report-job.schema.ts:13-18).
4. Cubit wiring (13 §1)
class ReportJobDetailCubit extends Cubit<JobDetailState> {
ReportJobDetailCubit(this.api) : super(JobDetailState.initial());
void open(String jobId) {
poller.start(jobId, (doc) => emit(state.copyWith(
job: doc, status: doc.status,
// terminal → emit completed/failed; cubit stops poller
)), (e) => emit(state.copyWith(pollPaused: true)));
}
void retry() {
// re-POST params (reports.service.ts:31-43) → new jobId → pushReplacement
}
}
Never predict status; mirror the server doc only (13 §2 mermaid).
5. Download & viewer (planned)
Until GET /reports/:jobId/download (04-Modules/Reports.md:26) exists:
- Hide export CTAs (
06 §S5); no download manager code ships.
When it lands:
- Download manager:
GET /reports/:jobId/download→ stream to app docs dir (path_provider),Content-Dispositionfilename parsed from headers (files.controller.ts:55-64pattern —attachment; filename="…"); notify completion; keep per-tenant folder. - Viewer: open PDF via
flutter_pdfviewor share viashare_plus; never load the whole file into memory for large exports (streaming,:49). - Alternate path: server stores via files module → client uses
GET /files/:id/download(file.read,files.controller.ts:55-64).
6. Scheduler & notifications (forward-looking)
- Scheduled jobs (
attendance-report.job.ts:13-24) surface via history once a list API exists (12 E4). - Completion notification: subscribe when server emits
ReportGenerated(04-Modules/Reports.md:33) — deep link/reports/jobs/:jobId.
7. Offline
- History + last-good job/result cached (
13 §3); banner + paused poll (10 §4); no writes offline (generate requires network).
8. Testing
- Unit: cubit transitions, poller pause/resume/jitter, renderer mapping
(
result→ models per06 §S5). - Widget: S4 status swaps with fake poller; S2 validation (
08 §3). - Golden: metric cards with
tabularFigures(11 §4). - E2E (
00-shared/10): mock API — full journey J1/J2/J3 (03); failure paths: 404, job failed, network drop mid-poll.
01 — Product Overview (Results Module)
- 1. Purpose
- 2. Scope
- 3. Out of scope (Phase 1 backend)
- 4. Key behavioural facts from source
- 5. Success metrics
- 6. Open questions / gaps
StudyLyon — multi-tenant ERP / School Management API. This package designs the Results module client (Flutter, forward-looking spec) against the implemented NestJS backend. Every endpoint, DTO field, schema, index, event, and permission is derived directly from
src/modules/results/**,src/modules/exams/**,src/infrastructure/bullmq/event-queue-map.ts,src/modules/rbac/permissions.constants.ts, anddocs/IMPLEMENTATION_PLAN.md. No feature is invented; gaps are flagged in Open Questions and marked(planned)/(forward-looking)/(proposed)inline. The PRD keeps the mobile client out of Phase 1 — this entire package is the(forward-looking)client design against the v1 API.
1. Purpose
Results is the grading and reporting layer of the academic cycle. Teachers enter
marks per (exam-subject, student), the backend computes totals, percentage, and
an overall grade per report card, and publication releases results to stakeholders.
The module owns the examination_results collection (examination-result.schema.ts:7-8)
but marks are written by the Exams module service (examination.service.ts:176-182);
the Results controller proxies marks entry into that service (result.controller.ts:26-31).
| Responsibility | Source |
|---|---|
| List results for one student | result.controller.ts:16-20 → result.service.ts:36-38 |
| List results for one exam-subject | result.controller.ts:21-25 → result.service.ts:40-44 |
| Enter/update marks (idempotent upsert) | result.controller.ts:26-31 → examination.service.ts:139-195 |
| Report card per (student, exam) | result.controller.ts:32-37 → result.service.ts:46-98 |
| Publish all results of an exam | examination.controller.ts:54-56 → examination.service.ts:203-220 |
2. Scope
- Marks entry per exam-subject, one student at a time, with idempotent re-entry (upsert,
examination.service.ts:147-175). - Result lookup by student and by exam-subject.
- Report card generation: per-subject marks, totals, percentage (2 decimals,
result.service.ts:83-86), overall grade via deterministic bands (result.service.ts:130-138). - Publication of an exam's results, which stamps
publishedAton every result row and flips the exam tostatus: 'published'(examination.service.ts:203-220). - Notification of publication via
ExamResultsPublished→in-appqueue /results-publishedjob (event-queue-map.ts:27).
3. Out of scope (Phase 1 backend)
- Student/parent portal visibility — no per-role visibility filter exists in code; every endpoint is behind
JwtAuthGuardonly (result.controller.ts:10). BlueprintResults.md"before publish, results hidden" is not implemented (see 4.2). - Rank computation — blueprint
Results.md:54says "Rank computed per class per exam", but no rank logic exists inresult.service.ts.(planned) - Analytics — blueprint lists
GET /api/v1/results/analytics(Results.md:28); no controller route exists.(proposed) - PDF report card export — blueprint assigns it to the Reports module (
Results.md:57); no implementation.(planned) - Mobile/Flutter client — the PRD keeps mobile out of Phase 1; this spec is the forward-looking client design against the v1 API.
(forward-looking) - RBAC permissions —
permissions.constants.ts:1-97contains noresult.*orexam.*permissions; the blueprint'sresult.read/compute/publish/report(Results.md:62-69) are unimplemented.(planned)
4. Key behavioural facts from source
- Marks upsert is idempotent:
examination.service.ts:147-150looks up by(studentId, examinationSubjectId); existing row is updated in place (:152-161), otherwise created (:176-182). A client can safely re-submit. - Unique index:
{tenantId, studentId, examinationSubjectId}unique (examination-result.schema.ts:31-33) — the DB-level backstop for the upsert. - Missing marks count as 0 in the report card (
result.service.ts:70result?.marksObtained ?? 0), buttotalMaximumMarksstill sums every subject (:72). A student with no entries is graded F, not excluded. - Over-maximum marks throw 404 (
examination.service.ts:145-146,NotFoundException) — a quirk: business-rule violations should be 422BUSINESS_RULE_VIOLATION, but this path returns 404RESOURCE_NOT_FOUND. The client must treat both as "marks rejected" (see 14_QA_Checklist.md). - Publication is not an immutability lock:
publishResults(examination.service.ts:203-220) only stampspublishedAtand sets exam status; marks can still be upserted afterwards. There is no "frozen" check inenterMarks. - Overall grade bands (
result.service.ts:130-138): A+ ≥ 90, A ≥ 80, B+ ≥ 70, B ≥ 60, C ≥ 50, D ≥ 40, F < 40 — only the overall grade is computed server-side. Per-subjectgradeis client-supplied (optional string inEnterMarksDto,examination-subject.dto.ts:57-60) and stored verbatim (examination.service.ts:157). - Report card subject name is the subject ID:
subjectName: sub.subjectId.toString()(result.service.ts:75) — no populate/join; the client must resolve display names locally.
5. Success metrics
- Marks entry converges in ≤ 2 attempts (idempotent upsert → no duplicate/conflict errors).
- Report card always renders even with missing marks (0-fill) or zero subjects (404 → guided empty state).
- Publish action visibly stamps results and fires the in-app
results-publishednotification.
6. Open questions / gaps
| # | Gap | Status |
|---|---|---|
| 1 | No role-based visibility: students/parents can't be restricted to their own results | (planned) — RBAC guards not yet implemented (AGENTS.md) |
| 2 | publishedAt is never filtered on read paths (examination-result.repository.ts:20-40) | (planned) |
| 3 | Per-subject grade computed? Currently client-sent; blueprint implies server grading | Open question |
| 4 | Rank/percentile per class per exam | (planned) |
| 5 | Bulk marks entry (grid POST) — only per-student enterMarks exists | (planned) — /:id/marks-import in IMPLEMENTATION_PLAN.md:219 |
| 6 | Analytics endpoint | (proposed) |
02 — User Personas (Results Module)
- 2.1 Teacher (subject examiner) — primary marks-entry user
- 2.2 Exam coordinator / academic head — publication owner
- 2.3 Student (and parent) — consumer of published results
- 2.4 Institution admin — configuration & oversight
- 2.5 (forward-looking) Platform operator
- Persona × capability matrix (today vs intended)
Personas are grounded in the code's actor model: every request carries a JWT (
JwtAuthGuard,result.controller.ts:10), tenant comes from token (base.repository.ts:20-30), andactorIdis recorded on domain events (examination.service.ts:167,result.service.ts:117). Roles beyond the guard are not yet enforced (noresult.*permissions inpermissions.constants.ts:1-97).
2.1 Teacher (subject examiner) — primary marks-entry user
- Goal: record marks for each student per exam-subject, correctly and fast; fix mistakes.
- Uses:
POST /api/v1/results/exam-subject/:examSubjectId/marks(result.controller.ts:26-31). - Needs: subject list per exam (
GET /api/v1/examinations/:id/subjects,examination.controller.ts:51-53), student list, max-marks context (maximumMarks,examination-subject.schema.ts:27-28), per-student re-entry without duplicates. - Pain: over-max marks are rejected with 404 (
examination.service.ts:145-146) — confusing error text must be surfaced by the client. - Permission today: any valid JWT. Intended:
result.compute/exam.mark(planned).
2.2 Exam coordinator / academic head — publication owner
- Goal: verify completeness of marks, then release results atomically per exam.
- Uses:
POST /api/v1/examinations/:id/publish(examination.controller.ts:54-56). - Needs: completion overview per exam (how many students have entries), preview report cards, publish confirmation; cannot un-publish (no such endpoint).
- Permission today: any valid JWT. Intended:
result.publish(planned).
2.3 Student (and parent) — consumer of published results
- Goal: see report card per exam: subject-wise marks, totals, percentage, overall grade.
- Uses:
GET /api/v1/results/report-card/:studentId/:examId(result.controller.ts:32-37) andGET /api/v1/results/student/:studentId(:16-20). - Reality gap: no server-side check that the JWT subject owns
studentId; nopublishedAtfilter (examination-result.repository.ts:20-40). The client must gate this experience behind publish state and later server RBAC(planned). - Pain:
subjectNamein report cards is the raw subject ID (result.service.ts:75) — client must map IDs → display names.
2.4 Institution admin — configuration & oversight
- Goal: define exams (
POST /api/v1/examinations,examination.controller.ts:27-29), add subjects withmaximumMarks/passingMarks(examination-subject.schema.ts:27-31), monitor publish history. - Uses: full examinations CRUD (
examination.controller.ts:27-43). - Permission today: any valid JWT. Intended:
exam.create/update/delete,result.read(planned).
2.5 (forward-looking) Platform operator
- Cross-tenant support: platform admin bypasses tenant scope (
base.repository.ts:21-23) — out of Phase 1 client scope.
Persona × capability matrix (today vs intended)
| Capability | Teacher | Coordinator | Student/Parent | Admin |
|---|---|---|---|---|
| Enter marks (upsert) | ✅ today | ✅ today | — | ✅ today |
| Read results | ✅ today (all) | ✅ today | ⚠️ not scoped | ✅ today |
| Report card | ✅ today | ✅ today | ⚠️ not scoped | ✅ today |
| Publish exam | ✅ today | ✅ today | — | ✅ today |
| RBAC-restricted | (planned) | (planned) | (planned) | (planned) |
⚠️ Today every endpoint is available to any authenticated user; the matrix above reflects the intended design (
permissions.constants.tshas noresult.*entries yet).
03 — User Journey (Results Module)
- Journey A — Teacher: enter marks for one exam-subject (happy path)
- Journey A′ — rejected marks (error path)
- Journey B — Coordinator: verify & publish exam results
- Journey C — Student: view report card
- Journey D — Admin: create exam + subjects (precondition to A)
- Journey E — (planned) bulk marks import
- Flow summary
Journeys map to real endpoints; async hops go through BullMQ (
event-queue-map.ts:27—ExamResultsPublished→in-app/results-published). States marked(planned)are not implemented server-side.
Journey A — Teacher: enter marks for one exam-subject (happy path)
- Open exam → subject list:
GET /api/v1/examinations/:id/subjects(examination.controller.ts:51-53). - Open subject detail: shows
date,startTime/endTime,maximumMarks,passingMarks(examination-subject.schema.ts:18-31). - Load existing entries:
GET /api/v1/results/exam-subject/:examSubjectId(result.controller.ts:21-25). - Enter marks per student:
POST /api/v1/results/exam-subject/:examSubjectId/markswithEnterMarksDto(examination-subject.dto.ts:47-65). - Re-enter to correct → same endpoint, server updates in place (idempotent upsert,
examination.service.ts:147-175); no 409 duplicates. - Client keeps local draft; syncs in background (see 15_Flutter_Implementation_Guide.md §Offline).
Journey A′ — rejected marks (error path)
- Marks >
maximumMarks→ server throws 404RESOURCE_NOT_FOUND(examination.service.ts:145-146). Client normalises to "marks cannot exceed maximum" and keeps the field in error state. See 14_QA_Checklist.md §5 for the 404-vs-422 quirk. - Missing/invalid
studentIdorexamSubjectId→VALIDATION_ERROR(400) per 07_API_Conventions.md.
Journey B — Coordinator: verify & publish exam results
- Open exam detail:
GET /api/v1/examinations/:id(examination.controller.ts:33-35). - Check status (
draft | active | completed | published,examination.schema.ts:28-33). - Inspect subject-wise completion:
GET /api/v1/examinations/:id/subjects+ per-subject results (Journey A step 3). - Publish:
POST /api/v1/examinations/:id/publish(examination.controller.ts:54-56).- Server stamps
publishedAton every result row of every subject (examination.service.ts:207), sets examstatus: 'published'(:209-211), emitsExamResultsPublished(:212-219) →in-appqueue →results-publishedjob (event-queue-map.ts:27).
- Server stamps
- Student/parent in-app notification delivered (async — job on
in-appqueue). - ⚠️ No un-publish endpoint exists. No immutability lock after publish (
enterMarksstill works) — by design for now, flagged in 14_QA_Checklist.md §2.
Journey C — Student: view report card
- Open results list:
GET /api/v1/results/student/:studentId(result.controller.ts:16-20) → list ofexamination_resultsrows withmarksObtained,grade,remarks,publishedAt(examination-result.schema.ts:15-25). - Open report card for an exam:
GET /api/v1/results/report-card/:studentId/:examId(result.controller.ts:32-37). - Read: per-subject
marksObtained/maximumMarks/grade/remarks; totals;percentage(2 decimals);overallGrade(A+…F bands,result.service.ts:130-138);generatedAt(result.service.ts:96). - Missing entries render as 0 with grade F (
result.service.ts:70) — client should visually distinguish "0 entered" from "not entered" (data limitation: cannot distinguish; see 14_QA_Checklist.md §3). - ⚠️
subjectNameis the raw subject ID (result.service.ts:75) — client maps to display names. - Empty exam (no subjects) → 404
No subjects found for this examination.(result.service.ts:51-52) → client shows empty state.
Journey D — Admin: create exam + subjects (precondition to A)
POST /api/v1/examinations(examination.controller.ts:27-29) — always startsstatus: 'draft'(examination.service.ts:48); body perCreateExaminationDto(examination.dto.ts:4-26):academicYearId,name,type(midterm|final|unit_test|quarterly|other),startDate,endDate.POST /api/v1/examinations/:id/subjects(examination.controller.ts:45-50) withCreateExaminationSubjectDto(examination-subject.dto.ts:11-45):subjectId,classId,date,startTime,endTime,maximumMarks(≥1),passingMarks(≥1).- Marks entry enabled (Journey A).
Journey E — (planned) bulk marks import
POST /api/v1/examinations/:id/marks-import— spreadsheet import with validation + rollback (IMPLEMENTATION_PLAN.md:219).(planned)GET /api/v1/examinations/:id/schedule(hall tickets/seatingIMPLEMENTATION_PLAN.md:216-218)(planned).
Flow summary
flowchart LR
A[Exam created draft] --> B[Subjects added]
B --> C[Marks entered per student]
C -->|POST marks| D{valid <= maximumMarks?}
D -->|no| E[404 → client shows 'exceeds max']
D -->|yes| F[Upsert examination_results row]
F --> G[Coordinator: POST publish]
G --> H[stamp publishedAt + exam status=published]
H --> I[ExamResultsPublished event]
I --> J[in-app queue → results-published job]
C --> K[Report card GET]
K --> L[Totals + % + overallGrade computed]
04 — Information Architecture (Results Module)
- 1. IA map
- 2. Information units (from contracts)
- 3. Navigation rules
- 4. State flags driving IA
- 5. Empty & error placements
IA derives from route structure (
result.controller.ts:9-37,examination.controller.ts:23-56) and the report card contract (result.service.ts:8-26). Navigation labels are client-side; the API shape constrains what each screen can show.
1. IA map
Results (tab / section)
├── Student Results List ← GET /api/v1/results/student/:studentId
│ └── Exam result row (per examination_results row)
│ └── Report Card View ← GET /api/v1/results/report-card/:studentId/:examId
│ ├── Subject rows (marksObtained, maximumMarks, grade, remarks)
│ ├── Totals (totalMarksObtained, totalMaximumMarks)
│ ├── Percentage (2 decimals)
│ └── Overall grade (A+…F)
Examinations (tab / section)
├── Exam List ← GET /api/v1/examinations (paginated)
│ └── Exam Detail ← GET /api/v1/examinations/:id
│ ├── Subject list ← GET /api/v1/examinations/:id/subjects
│ │ └── Exam-Subject Detail
│ │ ├── Results grid ← GET /api/v1/results/exam-subject/:examSubjectId
│ │ └── Marks entry form → POST /api/v1/results/exam-subject/:examSubjectId/marks
│ └── Publish action → POST /api/v1/examinations/:id/publish
│ └── (async) in-app notification → results-published job
2. Information units (from contracts)
| Unit | Fields | Source |
|---|---|---|
| Examination | academicYearId, name, type, startDate, endDate, status (draft/active/completed/published), gradingSchemeId? | examination.schema.ts:8-36 |
| ExaminationSubject | examinationId, subjectId, classId, date, startTime, endTime, maximumMarks, passingMarks | examination-subject.schema.ts:8-31 |
| ExaminationResult | studentId, examinationSubjectId, marksObtained?, grade?, remarks?, publishedAt? + tenantId/timestamps | examination-result.schema.ts:8-25, base.schema.ts |
| ReportCard | studentId, examinationId, subjects[] (subjectId, subjectName*, marksObtained, maximumMarks, grade?, remarks?), totalMarksObtained, totalMaximumMarks, percentage, overallGrade, generatedAt | result.service.ts:8-26 |
| Marks entry input | studentId, marksObtained, grade?, remarks? | examination-subject.dto.ts:47-65 |
* subjectName currently equals the subject ID (result.service.ts:75) — see gaps.
3. Navigation rules
- Marks entry lives under Examinations, not Results: the write endpoint is proxied from
result.controller.ts:26-31into the Exams service, and subjects are exam-owned. Results tab = read-only (student lists + report cards). - Publish is exam-scoped (
examinations/:id/publish), not result-scoped; UI must place it on exam detail, not on a single result row. - Report card requires both
studentIdandexamIdpath params (result.controller.ts:32-37) — navigation must always carry the pair.
4. State flags driving IA
| Flag | Derivation | UI effect |
|---|---|---|
| Exam status | examination.schema.ts:28-33 | published → mark entry read-only*; else editable |
| Result published | publishedAt on row (examination-result.schema.ts:24-25) | badge "Published" on result rows/list items |
| Entry exists | row present for (student, subject) | grid cell shows marks vs. empty |
* Server does not enforce read-only after publish (examination.service.ts:139-195 has no published check) — UI-only for now, flagged (planned).
5. Empty & error placements
- Zero subjects for exam → report card 404 (
result.service.ts:51-52) → screen-level empty state (see06_Screen_Specifications.md). - No results rows for a student →
findByStudentreturns[](repository returns array,examination-result.repository.ts:26-28) → empty state, no error. - Marks > max → 404 at
examination.service.ts:145-146→ inline field error (client normalises code).
05 — Screen Inventory (Results Module)
Every screen maps 1:1 to a real endpoint or DTO. No screen is invented; five screens cover the module.
(forward-looking)= client screens against the implemented API.
| # | Screen | Route (client) | Primary endpoint | Source |
|---|---|---|---|---|
| S1 | Marks Entry Grid (per exam-subject) | /examinations/:examId/subjects/:examSubjectId/marks | GET /api/v1/results/exam-subject/:examSubjectId + POST …/marks | result.controller.ts:21-31 |
| S2 | Exam-Subject Detail | /examinations/:examId/subjects/:examSubjectId | GET /api/v1/examinations/:id/subjects (+ entry counts via S1) | examination.controller.ts:51-53, examination-subject.schema.ts:8-31 |
| S3 | Student Results List | /students/:studentId/results | GET /api/v1/results/student/:studentId | result.controller.ts:16-20 |
| S4 | Report Card View | /students/:studentId/results/:examId | GET /api/v1/results/report-card/:studentId/:examId | result.controller.ts:32-37 |
| S5 | Grade Summary (per exam, per class — completion overview) | /examinations/:examId/grades | Derived: GET /api/v1/examinations/:id/subjects → per-subject results | examination.controller.ts:51-53, result.controller.ts:21-25 |
Screen purposes
S1 — Marks Entry Grid
- Data: all
ExaminationResultrows for one exam-subject (examination-result.repository.ts:20-24), one editable row per student:marksObtained,grade(client-supplied string),remarks(optional). - Writes: per-row
POST /api/v1/results/exam-subject/:examSubjectId/marks(idempotent upsert,examination.service.ts:147-175). - Context header: subject
date,startTime/endTime,maximumMarks,passingMarks(examination-subject.schema.ts:18-31).
S2 — Exam-Subject Detail
- Data: one
ExaminationSubjectrow; entry coverage (entered/total students — computed client-side from S1 data). - Actions: navigate to S1; shows subject schedule info.
S3 — Student Results List
- Data: all
ExaminationResultrows for a student (examination-result.repository.ts:26-28) — flat rows, not grouped by exam (no grouping endpoint exists; client groups byexaminationSubjectIdor via exam lookup). - Action: open S4 for any exam with a report card.
S4 — Report Card View
- Data:
ReportCardobject (result.service.ts:8-26): subject rows, totals,percentage,overallGrade,generatedAt. - Read-only. Errors: 404 when exam has no subjects (
result.service.ts:51-52).
S5 — Grade Summary (completion & distribution overview)
- Data: subjects of exam (S2 endpoint) + results per subject (S1 endpoint).
- Shows per-class/per-subject: entries entered vs students, marks distribution; publish readiness.
- No dedicated backend endpoint —
(proposed)for a server-side aggregation (blueprintGET /api/v1/results/analytics,Results.md:28); today client-side aggregation only.
Gaps vs. screens
| Gap | Impact | Status |
|---|---|---|
| No bulk marks POST (one request per student) | S1 must serialize N upserts | (planned) — marks-import IMPLEMENTATION_PLAN.md:219 |
| No per-exam grouping endpoint for student rows | S3 groups client-side | (planned) |
| No rank | S4 cannot show rank | (planned) — Results.md:54 |
| No publish-lock | S1 stays editable after publish | (planned) |
| No analytics | S5 is client-aggregated | (proposed) |
| No PDF report card | S4 share/export is web-only | (planned) — Results.md:57 |
06 — Screen Specifications (Results Module)
- S1 — Marks Entry Grid
- S2 — Exam-Subject Detail
- S3 — Student Results List
- S4 — Report Card View
- S5 — Grade Summary (completion & distribution)
The largest doc: full behaviour per screen. References shared specs by name: 02_Design_Tokens.md (tokens
T-), 03_Component_Library.md (componentsC-), 09_Accessibility_Baseline.md (a11y rules),06_State_Management.md(cubits), 07_API_Conventions.md (envelope/errors). Server facts cited withfile:line. All screens(forward-looking)Flutter client design.
S1 — Marks Entry Grid
Route: /examinations/:examId/subjects/:examSubjectId/marks
Purpose: enter/update marks for every student for one exam-subject.
1. Data contract
| Field | Source |
|---|---|
Subject context: date, startTime, endTime, maximumMarks, passingMarks | examination-subject.schema.ts:18-31 |
Rows: studentId, marksObtained?, grade?, remarks?, publishedAt? | examination-result.schema.ts:8-25 |
Load: GET /api/v1/results/exam-subject/:examSubjectId | result.controller.ts:21-25 |
Save: POST /api/v1/results/exam-subject/:examSubjectId/marks body {studentId, marksObtained, grade?, remarks?} | result.controller.ts:26-31, examination-subject.dto.ts:47-65 |
2. Layout
- Header block (S1-H): exam name + subject + class; chips: date, time range,
maximumMarks,passingMarks, entry countn/students. - Table (S1-T): columns — student (name, id), marks obtained, grade, remarks, status. Rows sorted by student name (client-side).
- Bottom action bar (S1-A): "Save all pending" with pending count; "Sync" for offline draft.
- Status strip (S1-S): unsaved-changes count; last-sync time.
3. States
| State | Trigger | UI |
|---|---|---|
| Loading | cubit fetch of S1-T + header | C-AppSkeleton rows (shimmer, 8 rows) |
| Loaded, clean | fetch ok, no edits | Table + C-AppSnackbar-free header chip Draft |
| Loaded, dirty | ≥1 field edited | Save bar active; per-row unsaved dot |
| Saving row | upsert in flight | Row spinner on that row only (per-row concurrency, examination.service.ts:147-175 upsert) |
| Saved | 2xx | Row clears dirty state; C-AppSnackbar success < 3 s |
| Row rejected | 404 RESOURCE_NOT_FOUND from examination.service.ts:145-146 (marks > max) | Inline field error: "Marks cannot exceed {maximumMarks}"; row keeps dirty state |
| Network fail | timeout/offline | C-AppOfflineBanner; edits preserved in offline draft (15_Flutter §Offline) |
| Empty | exam-subject has no students yet | C-AppEmptyState: "No students to grade yet" |
Published (server row has publishedAt, examination-result.schema.ts:24-25) | row-level | Row locked + "Published" chip; UI-only lock — server still accepts writes (examination.service.ts:139-195 has no published check, (planned)) |
4. Components (from 03_Component_Library.md)
C-AppTextField (numeric, tabularFigures() per 02_Design_Tokens.md §2), C-AppSelect (grade), C-AppSnackbar, C-AppOfflineBanner, C-AppEmptyState, C-AppSkeleton, C-StatusChip (module: draft/saved/published), C-DataTable (module: virtualised, see 15_Flutter §Table), C-FilterChip (only-entered/only-missing toggle).
5. Tokens (02_Design_Tokens.md)
- Table header row:
surfaceContainerHigh; zebra:surface/surfaceContainerLow. - Entered marks:
onSurface; missing row:onSurfaceVariantitalic; over-max error:error+errorContainerfield fill. - Publish chip:
secondaryContainer-stylesuccess; draft chip:surfaceVariant. - Numerals
mono14/400/20 +FontFeature.tabularFigures().
6. Dark/light
Both modes identical structure; only roles swap per 02_Design_Tokens.md §1 (surfaceContainerHigh #ECE6E0 light / #2B2929 dark, etc.). Grid lines use outlineVariant in both.
7. Responsive
- ≥ 720 px: full table, columns as above.
- < 720 px: table → cards; per-student card: name header, marks field, grade dropdown, remarks field, status chip.
maximumMarkshint beside marks field in both. - Portrait phones: save bar fixed bottom (respect safe-area), sticky header block with subject chips scrollable horizontally.
8. Accessibility (09_Accessibility_Baseline.md)
- Table:
Semanticsheader row; each row labelled "Student {name}, marks {value} of {max}". - Marks field: label "Marks obtained — {student}" +
errorTextannounced viaSemanticsServiceon 404-reject. - Keyboard: Tab order = marks → grade → remarks per row; Enter saves row.
- Color not the only signal: published/missing states carry icon + text, not just chip color.
- Charts/tables a11y (see S5 §8): table content is real
Semantics, not image. - Focus visible on save bar; reduced motion: no per-row spinner animation (static progress).
S2 — Exam-Subject Detail
Route: /examinations/:examId/subjects/:examSubjectId
1. Data contract
Single ExaminationSubject (examination-subject.schema.ts:8-31): examinationId, subjectId, classId, date, startTime, endTime, maximumMarks, passingMarks. Source endpoint: GET /api/v1/examinations/:id/subjects (examination.controller.ts:51-53) — client picks the row by id (no single-row GET exists).
2. Layout
- Hero card: subject name (client-mapped from
subjectId), class. - Info grid: date;
startTime–endTime;maximumMarks;passingMarks. - Coverage card: entered count / total students + linear progress (
C-ProgressIndicator); computed client-side from S1 rows. - Primary action: "Enter marks" → S1. Secondary: "Grade summary" → S5 (filtered to this subject).
3. States
| State | Trigger | UI |
|---|---|---|
| Loading | subjects fetch | Hero C-AppSkeleton |
| Found | id in list | Full detail |
| Not found | id absent (or exam 404) | C-AppErrorState RESOURCE_NOT_FOUND + retry |
| Covered | count == total | Progress 100%, success tint |
| Partial | 0 < count < total | Progress partial, primary tint, "n remaining" hint |
| Zero | count == 0 | C-AppEmptyState compact, CTA "Enter marks" |
4–7. Components/Tokens/Responsive/A11y
- Same tokens as S1; info grid uses
bodyMediumonSurfaceVariantlabels. - Responsive: info grid 2 cols ≥ 600 px, 1 col below.
- A11y: coverage progress announces "marks entered for {n} of {total} students"; hero card single
Semanticsnode.
S3 — Student Results List
Route: /students/:studentId/results
1. Data contract
GET /api/v1/results/student/:studentId → array of ExaminationResult rows (examination-result.repository.ts:26-28): studentId, examinationSubjectId, marksObtained?, grade?, remarks?, publishedAt?, timestamps.
Flat rows, not grouped by exam — client groups by
examinationSubjectId→ exam lookup (GET /api/v1/examinations/:id,examination.controller.ts:33-35) to build the list.(planned)server-side grouping would remove N lookups.
2. Layout
- Grouped list (S3-L): exam group headers (name + status chip); items per subject: subject name, marks
obtained/max, grade chip, remarks. - FAB / header action: "Report card" per exam → S4 (needs both ids —
result.controller.ts:32-37). - Filter chip row: All / Published (
publishedAtset) / Unpublished / Grade (A+…F).
3. States
| State | Trigger | UI |
|---|---|---|
| Loading | fetch | C-AppSkeleton list |
| Empty | [] returned | C-AppEmptyState: "No results recorded" |
| Loaded | rows | Grouped list; unpublished items dimmed (onSurfaceVariant) |
| Published-only filter | filter chip | Only publishedAt != null groups (examination-result.schema.ts:24-25) |
| Error | 401/403/429 | C-AppErrorState per 07_API_Conventions.md codes |
4–7. Components/Tokens/Responsive/A11y
- Grade chip colors: A+/A
success, B+/Bprimary, Ctertiary, Dtertiary, Ferror(band mappingresult.service.ts:130-138). - Responsive: single-column list everywhere; filters scroll horizontally.
- A11y: each item announces "subject, marks of maximum, grade"; group headers
titleSmall+Semantics(header: true).
S4 — Report Card View
Route: /students/:studentId/results/:examId
1. Data contract
GET /api/v1/results/report-card/:studentId/:examId → ReportCard (result.service.ts:8-26):
| Field | Notes | Source |
|---|---|---|
studentId, examinationId | echo of path params | result.service.ts:18-19 |
subjects[] | subjectId, subjectName (= raw subject ID — client must map!), marksObtained (0 when missing, result.service.ts:70), maximumMarks, grade?, remarks? | result.service.ts:68-81 |
totalMarksObtained | sum incl. 0-filled missing | result.service.ts:65-71 |
totalMaximumMarks | sum of all subject maxima | result.service.ts:72 |
percentage | round(obtained/max × 100, 2) — computed server-side | result.service.ts:83-86 |
overallGrade | bands A+≥90, A≥80, B+≥70, B≥60, C≥50, D≥40, F<40 — computed server-side | result.service.ts:95, 130-138 |
generatedAt | ISO string, per-request | result.service.ts:96 |
2. Layout
- Card header: exam name, student name,
generatedAt(formatted local), overall grade hero chip. - Totals bar:
totalMarksObtained/totalMaximumMarks+percentage(mono, 2 decimals). - Subject table (S4-T): subject (mapped name), marks, max, grade chip, remarks.
- Actions: Share (web share —
(forward-looking)); Print/PDF —(planned)(Results.md:57, Reports module).
3. States
| State | Trigger | UI |
|---|---|---|
| Loading | fetch | C-AppSkeleton card + table |
| Success | 2xx | Full card |
| Exam has no subjects | 404 No subjects found for this examination. (result.service.ts:51-52) | C-AppErrorState → guided empty: "No subjects configured for this exam" |
| Student not found / cross-tenant | 404 (envelope per 07_API_Conventions.md §6) | C-AppErrorState RESOURCE_NOT_FOUND |
| Missing subject rows | marksObtained: 0, no grade | Row shows "0 / max" with "not entered" hint chip (data limitation: server cannot distinguish 0 from missing — result.service.ts:70) |
| Network | offline | C-AppOfflineBanner + cached card if previously loaded (15_Flutter §Cache) |
4–7. Components/Tokens/Responsive/A11y
- Grade hero:
displaySmall; totalsheadlineMediummono; subject table per S1 §5. - Dark/light: card surface
surfaceContainerLow; hero chip uses grade role colors (see S3). - Responsive: ≥ 600 px two-column (totals bar + grade hero side by side); below → stacked; table → stacked rows with labels.
- A11y: hero
Semantics(label: "Overall grade A plus")— never read "A+" as literal; table rows announced "Subject, marks x of y, grade z"; totals bar announced as one summary sentence.generatedAtinSemanticswith full date. Contrast of grade chips ≥ 4.5:1 (09_Accessibility_Baseline.md).
S5 — Grade Summary (completion & distribution)
Route: /examinations/:examId/grades
1. Data contract
No dedicated endpoint (proposed) — blueprint GET /api/v1/results/analytics (Results.md:28) unimplemented. Client aggregates:
GET /api/v1/examinations/:id/subjects(examination.controller.ts:51-53)- per subject:
GET /api/v1/results/exam-subject/:examSubjectId(result.controller.ts:21-25) Then computes client-side: coverage %, grade distribution (applying server bandsresult.service.ts:130-138to the server-computed report-card percentages where possible; per-subject grades are client-supplied strings, so distribution uses storedgradestrings), pass/fail vspassingMarks(examination-subject.schema.ts:30-31).
2. Layout
- Class filter (from subject
classId,examination-subject.schema.ts:15-16). - Coverage row: per-subject progress bars (entered / total).
- Distribution chart: horizontal bar per grade (A+…F) — counts of stored grades; ungraded bucket.
- Publish readiness banner:
C-AppBannerwarning when any subject has < 100% coverage;successwhen complete. - Action: "Publish results" → confirm dialog →
POST /api/v1/examinations/:id/publish(examination.controller.ts:54-56).
3. States
| State | Trigger | UI |
|---|---|---|
| Aggregating | N subject fetches in flight | Skeleton bars |
| Ready | all fetched | Charts + banner |
| Empty | no subjects | C-AppEmptyState "No subjects added to this exam" |
| Published exam | status published (examination.schema.ts:28-33) | Banner → "Results published on {date}"; publish action disabled |
4–7. Components/Tokens/Responsive/A11y
- Charts: module-specific simple bar chart (no chart dependency, see 07_Component_Library.md + 15_Flutter §Charts). Bars use grade role colors; ungraded =
surfaceVariant. - Tokens per S3 §5.
- Responsive: bars stack vertically < 600 px; chart has scrollable horizontal axis.
- A11y (charts/tables): every bar is a
Semantics-labelled node "Grade A, 12 students (24%)"; raw counts also rendered as text (never chart-only);C-AppBannerannounced viaSemanticsService(09_Accessibility_Baseline.md §charts).
8. Cross-cutting a11y notes (all screens)
- Charts and tables always ship a text equivalent; no pure-image data.
- All error surfaces map envelope codes → copy (07_API_Conventions.md §3); 5xx → generic +
requestId. - Focus order linear;
C-AppSnackbarerrors persist ≥ 4 s.
07 — Component Library (Results Module)
- R-DataTable (virtualised results grid)
- R-GradeChip
- R-MarksField
- R-CoverageBar
- R-GradeDistributionChart
- R-PublishPanel
Module-specific components. Shared components (
AppTextField,AppSelect,AppSnackbar,AppOfflineBanner,AppEmptyState,AppErrorState,AppSkeleton,AppBanner,StatusChip,ProgressIndicator,FilterChip) are referenced from 03_Component_Library.md — not reproduced here.
R-DataTable (virtualised results grid)
- Purpose: marks-entry grid for S1; report-card subject table for S4.
- Props:
columns,rows,editableCell,onCellChanged,rowStates(dirty/saving/saved/error/published),maxMarks. - Behaviour: virtualised (only visible rows built — see 15_Flutter §Table); per-row editing; numeric keyboard; tabular numerals (02_Design_Tokens.md §2).
- States: loading (skeleton), clean, dirty, row-save spinner, row-error (
errorContainerfill +errorText), published-locked. - A11y: header
Semantics; row label "Student {name}, marks {v} of {max}". - Tokens:
surfaceContainerHighheader,outlineVariantgridlines,mononumerals.
R-GradeChip
- Purpose: grade badge (A+/A/B+/B/C/D/F or client-supplied string).
- Props:
grade,size. - Behaviour: colour by band (
result.service.ts:130-138): A+/Asuccess, B+/Bprimary, C/Dtertiary, Ferror; unknown string →surfaceVariant+ neutral text. - A11y: announced as letters, never symbols ("grade A plus").
- Tokens: role-colour container +
on*Containertext,labelMedium.
R-MarksField
- Purpose: numeric marks input bound to a student row.
- Props:
studentId,value,maximumMarks,passingMarks,readOnly,onCommit. - Behaviour: local text state until commit; validation client-side
value ≤ maximumMarksmirrors server rule (examination.service.ts:145-146); over-max shows inline error without a round trip. - Tokens:
AppTextFieldtokens; errorerror/errorContainer.
R-CoverageBar
- Purpose: marks-entry completeness (S2, S5).
- Props:
entered,total. - Behaviour: 100 % →
success; partial →primary; 0 →surfaceVariant; label "n of m". - A11y:
Semantics(value: "entered n of m").
R-GradeDistributionChart
- Purpose: S5 per-grade bars.
- Props:
counts: Record<grade, number>,total. - Behaviour: horizontal bars per band incl. ungraded bucket; pure Flutter
Row/FractionallySizedBox— no chart dependency (see 15_Flutter §Charts). - A11y: each bar =
Semantics(label: "Grade {g}, {n} students ({p}%)"); counts always rendered as text. - Tokens: band colours per
R-GradeChip.
R-PublishPanel
- Purpose: S5 publish action with confirmation.
- Props:
examId,status,coverage,onPublish. - Behaviour: disabled unless coverage 100 % (advisory — server accepts regardless,
examination.service.ts:203-220); confirm dialog warns "cannot be reverted — no unpublish endpoint"; on success bannersuccess"Results published", firesPOST /api/v1/examinations/:id/publish(examination.controller.ts:54-56). - Tokens:
successcontainer /primaryFAB.
08 — Form Specifications (Results Module)
- F1 — Marks entry (per student, per exam-subject)
- F2 — Publish confirmation (dialog, S5)
- F3 — Form validation summary (envelope codes)
Only one real form exists in the domain: the marks entry form (S1), whose contract is
EnterMarksDto(examination-subject.dto.ts:47-65). S5's publish confirmation is a dialog, not a form. Field-by-field below, including the server-side validation each field maps to.(forward-looking)client spec.
F1 — Marks entry (per student, per exam-subject)
Submit: POST /api/v1/results/exam-subject/:examSubjectId/marks
(result.controller.ts:26-31) — one submission per student row; idempotent upsert
(examination.service.ts:147-175).
F1.1 studentId
| Type | hidden — from the selected row; never user-editable |
| Server rule | @IsMongoId() required (examination-subject.dto.ts:49-50) |
| Errors | 400 VALIDATION_ERROR if malformed |
| Notes | Row identity; unique with examinationSubjectId per DB index (examination-result.schema.ts:31-33) |
| A11y | Semantics(exclude) on the hidden field; row label carries the student name |
F1.2 marksObtained
| Type | numeric, decimal allowed (backend IsNumber has no integer constraint, :53-55) |
| Keyboard | TextInputType.numberWithOptions(decimal: true); mono + tabularFigures() |
| Client rule | 0 ≤ marks ≤ maximumMarks — mirrors server marksObtained > subject.maximumMarks → 404 (examination.service.ts:145-146) |
| Server rules | @IsNumber() @Min(0) required (examination-subject.dto.ts:52-55) |
| Hint | "Max {maximumMarks}" beside field (examination-subject.schema.ts:27-28) |
| Errors | Client: over-max inline error before submit (no round trip). Server rejects still possible (stale maximumMarks): 404 RESOURCE_NOT_FOUND → normalise to "marks cannot exceed maximum" (404-vs-422 quirk, 14_QA_Checklist.md §5). 400 if negative or non-numeric. |
| Focus | auto-advance Enter → grade field; commit-on-blur + Enter |
| A11y | label "Marks obtained — {student}"; errorText announced via SemanticsService |
F1.3 grade (optional)
| Type | dropdown of A+, A, B+, B, C, D, F + "clear" — but server accepts any string (@IsOptional() @IsString(), examination-subject.dto.ts:57-60), stored verbatim (examination.service.ts:157) |
| Default | empty (server derives no per-subject grade; only the overall grade is computed, result.service.ts:130-138) |
| Note | Client-supplied; do not auto-derive from marks client-side unless the org's scheme is known — flag: server grading (planned) (blueprint Results.md:54-55) |
| Errors | 400 if non-string |
| A11y | label "Grade — {student}"; selected value announced via R-GradeChip semantics ("grade A plus") |
F1.4 remarks (optional)
| Type | free text, single-line AppTextField |
| Server rule | @IsOptional() @IsString() (examination-subject.dto.ts:62-65) — no max length in code |
| Client rule | cap 200 chars locally (proposed) — no server contract exists |
| Errors | 400 if non-string |
| A11y | label "Remarks — {student}" |
F1.5 Submission semantics
- Per-row commit, not a whole-grid POST (no bulk endpoint —
(planned)marks-import,IMPLEMENTATION_PLAN.md:219). - Re-submitting the same row is an update, never a duplicate (upsert
examination.service.ts:147-175). - Partial payloads:
grade/remarksomitted → cleared on update ($setwith undefined fields is not applied — only provided keys are written,:152-161); send all three keys to avoid surprise. - Offline: rows queue in the offline draft and replay in row order (15_Flutter §Offline).
F2 — Publish confirmation (dialog, S5)
| Trigger | "Publish results" → POST /api/v1/examinations/:id/publish (examination.controller.ts:54-56) |
| Fields | none — confirmation copy only: exam name, coverage %, warning "results become visible; cannot be un-published (no endpoint)" |
| Commit | C-AppButton "Publish" (destructive-affirmative), dismissible "Cancel" |
| Result | 2xx → banner "Results published"; failure 404 (exam missing) / 429 / 5xx → C-AppErrorState in dialog |
F3 — Form validation summary (envelope codes)
| Condition | Client | Server |
|---|---|---|
| marks > maximumMarks | inline error, no submit | 404 RESOURCE_NOT_FOUND (examination.service.ts:145-146) |
| negative marks | Min(0) inline | 400 VALIDATION_ERROR |
| malformed id | — | 400 VALIDATION_ERROR |
| wrong tenant / deleted subject | — | 404 RESOURCE_NOT_FOUND (examination.service.ts:143-144, scoped repo) |
| duplicate race | impossible by design (upsert) | unique index backstop (examination-result.schema.ts:31-33) → dup-key 5xx if raced, flagged 14_QA_Checklist.md §6 |
09 — User Behaviour (Results Module)
- B1 — Marks entry is incremental and re-entrant
- B2 — Client-side guardrails beat server round trips
- B3 — Missing marks read as zero on the report card
- B4 — Publish is a point of no return
- B5 — Publication informs, asynchronously
- B6 — The results list is a flat feed
- B7 — Filters are local until the server supports them
- B8 — Error recovery is code-driven, not message-driven
- B9 — Offline-first grading
- B10 — Behavioural metrics (client instrumentation
(proposed))
Behavioural requirements derived from server semantics. Each behaviour cites the code fact that justifies it. Reference 00-shared/08_Interaction_&_Motion.md for motion defaults and 00-shared/09_Accessibility_Baseline.md for a11y obligations.
B1 — Marks entry is incremental and re-entrant
- Teachers type marks row-by-row; each committed row is an independent upsert
(
examination.service.ts:147-175). The UI must not block other rows on one failing save (per-row saving state, S1 §3). - Re-entry must be frictionless: the same row can be re-submitted any number of
times (update path
:152-161). No "already entered" blocking.
B2 — Client-side guardrails beat server round trips
- Over-max and negative values are caught client-side (08_Form_Specifications.md F1.2)
because the server's rejection (404,
examination.service.ts:145-146) is semantically wrong for the user. Users expect "try again", not "not found".
B3 — Missing marks read as zero on the report card
result.service.ts:70fillsmarksObtained: 0and totals still include the subject maximum (:72). Behaviour rule: never silently show a plausible zero — S4 marks the "not entered" row (hint chip) and S1 shows empty rows, so users can tell "0 scored" (has a row) from "not graded" (no row). Server cannot distinguish.
B4 — Publish is a point of no return
publishResults(examination.service.ts:203-220) is the only visibility switch; no unpublish endpoint exists. The confirm dialog must state this. After publish, S5 disables the action; S1 rows show locked-but-editable state (server still accepts writes —(planned)lock).
B5 — Publication informs, asynchronously
- The visible effect of publish is the
ExamResultsPublishedevent →in-appqueue →results-publishedjob (event-queue-map.ts:27). Users should be told "notification queued", not "notification sent" — delivery is async.
B6 — The results list is a flat feed
GET /api/v1/results/student/:studentIdreturns flatexamination_resultsrows (examination-result.repository.ts:26-28). Users expect exam-grouped results; the client groups (S3) and must tolerate rows whose exam lookup 404s (exam soft-deleted —examination.repository.tsscoped soft-delete) by dropping the orphan group silently.
B7 — Filters are local until the server supports them
- Filter chips on S3/S5 (published-only, grade, class) are client-side. A user
filtering "published only" must not assume server-side security —
publishedAtis never filtered on read paths (examination-result.repository.ts:20-40,(planned)).
B8 — Error recovery is code-driven, not message-driven
- Envelope codes (07_API_Conventions.md §3) drive copy:
VALIDATION_ERROR→ inline field errors;RESOURCE_NOT_FOUND→ keep input + explain (404 quirk §B2);RATE_LIMITED→ backoff + retry after window; 5xx → generic +requestId. - Marks save failures never discard the row edit — dirty state persists (S1 §3).
B9 — Offline-first grading
- Mobile context: teachers grade in classrooms with flaky connectivity. Edits must survive into an offline draft and replay in order when connectivity returns (15_Flutter §Offline). Idempotent upsert makes replay safe.
B10 — Behavioural metrics (client instrumentation (proposed))
- Save attempts per row (convergence ≤ 2, per 01_Product_Overview.md §5), rejection rate (404 quirk encounters), publish-to-view latency, offline-draft replay failures.
10 — Interaction Specification (Results Module)
- I1 — Marks Entry Grid (S1)
- I2 — Exam-Subject Detail (S2)
- I3 — Student Results List (S3)
- I4 — Report Card (S4)
- I5 — Grade Summary & Publish (S5)
- I6 — Motion & feedback summary
Precise interactions per screen. Motion defaults per 00-shared/08_Interaction_&_Motion.md; a11y obligations per 00-shared/09_Accessibility_Baseline.md; components
C-*per 00-shared/03_Component_Library.md,R-*per 07_Component_Library.md.
I1 — Marks Entry Grid (S1)
I1.1 Keyboard & focus
- Tab order per row:
marksObtained→grade→remarks→ next row'smarksObtained. - Enter commits the focused cell and saves the row (per-row upsert,
examination.service.ts:147-175); Tab fromremarksmoves to next student's marks field (when grid layout) or commits + moves (card layout). - Esc clears the row's dirty state back to last-saved value.
I1.2 Save choreography
- Row states:
clean → dirty → saving → saved | error(S1 §3). saving: inlineC-ProgressIndicatoron the row only — never a full-screen block (row independence).saved: row turns clean;C-AppSnackbar"Saved — {student}" < 3 s; multiple saves coalesce into one snackbar.error: field showserrorText; row stays dirty; snackbar ≥ 4 s withrequestIdfor 5xx.- "Save all pending": queues dirty rows in order; each still fires its own POST; failures leave that row dirty and continue others.
I1.3 Offline
- Connectivity lost mid-entry →
C-AppOfflineBanner; current edit remains in the field; row enters the offline draft (15_Flutter §Offline). - Reconnect → banner "n unsynced rows" with Sync action; replay in row order; per-row success/failure surfaced (failed rows stay dirty).
I1.4 Published rows
- Rows with server
publishedAt(examination-result.schema.ts:24-25) render locked (fields disabled, "Published" chip). UI-only — server still accepts writes ((planned)lock).
I2 — Exam-Subject Detail (S2)
- Coverage progress animates width 300 ms on load (08_Interaction_&_Motion.md); announces once via
SemanticsService— no repeated announcements on rebuild. - "Enter marks" → push S1; return preserves S1's dirty state (keep cubit alive in the navigator stack).
I3 — Student Results List (S3)
- Pull-to-refresh refetches rows and re-groups; cache is refreshed, not evicted first (renders from old + updates in place).
- Filter chips are instant, client-side, and sticky during session.
- Tapping a group header collapses/expands the group (default expanded).
I4 — Report Card (S4)
- Read-only: no edits, no pull-to-refresh; data is server-computed (
result.service.ts:46-98). - Share action (web share sheet) shares a text summary
(forward-looking); Print/PDF(planned)(Results.md:57). - Stale display: if exam
statusflips topublishedwhile the card is open (in-app eventresults-published), refresh the card and show the "Published" chip without user action.
I5 — Grade Summary & Publish (S5)
- Aggregation runs N subject fetches; show skeleton bars; results stream in as each resolves (partial render).
- Publish button: disabled until coverage = 100 % (advisory); on tap →
C-AppDialogconfirm (B4 warning, no-revert copy) →POST /api/v1/examinations/:id/publish(examination.controller.ts:54-56) → on 2xx replace banner with "Results published on {date}". - On 429
RATE_LIMITED: disable publish, show countdown of the retry window.
I6 — Motion & feedback summary
| Action | Feedback | Duration |
|---|---|---|
| Row save start | inline row spinner (static under reduced motion) | until response |
| Row saved | snackbar success | < 3 s |
| Row rejected | inline error + snackbar (4 s) | ≥ 4 s |
| Publish success | banner + snackbar | banner persists |
| Offline | banner (persists until synced) | persists |
| Coverage change | progress bar 300 ms ease-out | 300 ms |
11 — Design System Mapping (Results Module)
- 1. Grade band → colour role
- 2. Surface tokens by screen
- 3. Typography
- 4. Spacing / radius / elevation
- 5. Iconography
- 6. Breakpoints & layout mapping
- 7. Dark/light parity notes
Maps every screen surface to 02_Design_Tokens.md (tokens
T-) and shared components (03_Component_Library.md). Dark/light parity per 02_Design_Tokens.md §10.
1. Grade band → colour role
Server bands (result.service.ts:130-138) map to semantic roles (light & dark both):
| Grade | Band | Role (light) | Role (dark) | Text |
|---|---|---|---|---|
| A+ | ≥ 90 | success container | success container | onSuccessContainer |
| A | ≥ 80 | success container | success container | onSuccessContainer |
| B+ | ≥ 70 | primary container | primary container | onPrimaryContainer |
| B | ≥ 60 | primary container | primary container | onPrimaryContainer |
| C | ≥ 50 | tertiary container | tertiary container | onTertiaryContainer |
| D | ≥ 40 | tertiary container | tertiary container | onTertiaryContainer |
| F | < 40 | error container | error container | onErrorContainer |
| unknown string | stored verbatim | surfaceVariant | surfaceVariant | onSurfaceVariant |
All chip text labelMedium (≥ 4.5:1, 09_Accessibility_Baseline.md §2).
2. Surface tokens by screen
| Surface | Token (light / dark) | Used on |
|---|---|---|
| Page background | surface | all screens |
| Card / hero | surfaceContainerLow | S2 hero, S4 card |
| Table header | surfaceContainerHigh | S1-T, S4-T |
| Table zebra | surface / surfaceContainerLow | S1-T rows |
| Table gridlines | outlineVariant | S1-T, S4-T |
| Field error fill | errorContainer | S1 over-max cells |
| Empty/disabled | surfaceVariant | ungraded bucket, unknown grade |
3. Typography
| Element | Type style (02_Design_Tokens.md §2) |
|---|---|
| Screen title | headlineSmall |
| S4 grade hero | displaySmall |
| S4 totals | headlineMedium mono, tabularFigures() |
| S1 table numerals | mono 14/400/20, tabularFigures() |
| Labels | labelMedium onSurfaceVariant |
| Missing-row text | bodyMedium onSurfaceVariant italic |
| Group headers (S3) | titleSmall |
4. Spacing / radius / elevation
- 4 dp grid per 02_Design_Tokens.md §3: table cell padding 8 dp vertical / 12 dp horizontal; card padding 16 dp; screen margins 16 dp.
- Radius: cards & chips
radius-medium(12), table cells 0 (straight grid), dialogsradius-large. - Elevation: cards
elevation-1(resting), publish panelelevation-2, save barelevation-2(fixed bottom), dialogelevation-3. - Touch targets ≥ 48×48 dp for all interactive cells (09_Accessibility_Baseline.md §3).
5. Iconography
| Icon | Where |
|---|---|
edit | S1 rows, S2 "Enter marks" |
check_circle / schedule | saved / draft status chips |
lock | published-locked rows |
cloud_off | offline banner |
bar_chart | S5 distribution |
campaign | publish action |
download / share | S4 actions ((planned) PDF) |
6. Breakpoints & layout mapping
| Width | S1 | S4 | S5 |
|---|---|---|---|
| < 600 px | cards layout, save bar fixed | stacked, table → labelled rows | bars stack vertical |
| 600–719 px | cards layout, save bar inline | two-column totals | stacked bars |
| ≥ 720 px | full table | two-column card + table | side-by-side chart grid |
7. Dark/light parity notes
- Identical structure both modes; only role pairs swap (02_Design_Tokens.md §1).
- Gridlines
outlineVariantdiffer per mode (#C4C7C5 light / #444746 dark) — never hardcode. - Grade chips use the same role mapping in both modes (verified contrast per mode).
12 — API Mapping (Results Module)
- E1 — List results for a student (S3)
- E2 — List results for an exam-subject (S1, S5)
- E3 — Enter / update marks (S1)
- E4 — Report card (S4)
- E5 — Get examination (S3 grouping, S5 header)
- E6 — List subjects of an exam (S1 header, S2, S5)
- E7 — Publish exam results (S5)
- E8 — List examinations (S1/S5 entry navigation) — paginated
- E9 — (planned) bulk marks import
- E10 — (proposed) analytics
- E11 — (planned) rank
- Event side-effect map
Exact endpoints per screen. Wire contract per 00-shared/07_API_Conventions.md: base
/api/v1, Bearer JWT, success{success:true, message:"OK", data, meta?, timestamp, requestId}, error{success:false, message, error:{code, details?}, timestamp, requestId}; codesVALIDATION_ERROR(400),UNAUTHENTICATED(401),PERMISSION_DENIED(403),RESOURCE_NOT_FOUND(404),DUPLICATE_RESOURCE(409),BUSINESS_RULE_VIOLATION(422),RATE_LIMITED(429),INTERNAL_SERVER_ERROR(5xx). Paginationpage/limit(1-100, default 20)/sort(-desc)/q. Sources:result.controller.ts,examination.controller.ts,examination.service.ts,result.service.ts. Note: no endpoint is paginated in this module — all results routes return bare arrays; pagination exists only on the exams list.
E1 — List results for a student (S3)
| Endpoint | GET /api/v1/results/student/:studentId (result.controller.ts:16-20) |
| Guard | JwtAuthGuard only (:10) — no ownership/visibility check; RBAC (planned) |
| Response | 200 envelope, data = flat ExaminationResult[] (examination-result.repository.ts:26-28) — no meta |
| Errors | 400 (malformed id); 429 RATE_LIMITED; 5xx; cross-tenant/deleted → [] (scoped repo, not 404) |
| Client | S3 rows; group by examinationSubjectId → E5 |
E2 — List results for an exam-subject (S1, S5)
| Endpoint | GET /api/v1/results/exam-subject/:examSubjectId (result.controller.ts:21-25) |
| Response | 200 envelope, data = ExaminationResult[] (examination-result.repository.ts:20-24) |
| Errors | 400; 404 via repo scoping (subject not in tenant → []); 429; 5xx |
| Client | S1 table rows; S5 per-subject aggregation |
| Notes | Includes rows with publishedAt — no filter param exists ((planned)) |
E3 — Enter / update marks (S1)
| Endpoint | POST /api/v1/results/exam-subject/:examSubjectId/marks (result.controller.ts:26-31) |
| Guard | JwtAuthGuard; intended result.compute (planned) |
| Body | EnterMarksDto (examination-subject.dto.ts:47-65): studentId (MongoId, req), marksObtained (Number, Min 0, req), grade? (String), remarks? (String) |
| Behaviour | idempotent upsert on (studentId, examSubjectId) (examination.service.ts:147-175); unknown subject → 404 (:143-144); marksObtained > maximumMarks → 404 RESOURCE_NOT_FOUND (:145-146, quirk — see 14_QA_Checklist.md §5) |
| Response | 2xx envelope, data = saved ExaminationResult |
| Errors | 400 VALIDATION_ERROR (bad id, negative marks); 404 (subject missing / over-max); 429; 5xx (incl. dup-key race → 500, examination-result.schema.ts:31-33) |
| Side effects | MarksEntered event → no queue route in event-queue-map.ts (audit only, examination.service.ts:163-173) |
| Client | S1 per-row save; replay-safe offline (idempotent) |
E4 — Report card (S4)
| Endpoint | GET /api/v1/results/report-card/:studentId/:examId (result.controller.ts:32-37) |
| Response | 200 envelope, data = ReportCard (result.service.ts:8-26): studentId, examinationId, subjects[] (subjectId, subjectName=subject ID string :75, marksObtained 0-filled :70, maximumMarks, grade?, remarks?), totalMarksObtained, totalMaximumMarks, percentage (2 dp :83-86), overallGrade (computeGrade bands :130-138), generatedAt |
| Errors | 404 No subjects found for this examination. (:51-52); 400 malformed ids; 429; 5xx |
| Client | S4; cache key (studentId, examId); subjectName → local display-name map |
| Notes | publishedAt not checked — report card works pre-publish ((planned) visibility) |
E5 — Get examination (S3 grouping, S5 header)
| Endpoint | GET /api/v1/examinations/:id (examination.controller.ts:33-35) |
| Response | 200 envelope, data = Examination (examination.schema.ts:8-36): academicYearId, name, type (`midterm |
| Errors | 404 Examination not found. (examination.service.ts:63); 429; 5xx |
| Client | S3 group headers (name + status chip); orphan-group drop when 404 |
E6 — List subjects of an exam (S1 header, S2, S5)
| Endpoint | GET /api/v1/examinations/:id/subjects (examination.controller.ts:51-53) |
| Response | 200 envelope, data = ExaminationSubject[] (examination-subject.schema.ts:8-31): examinationId, subjectId, classId, date, startTime, endTime, maximumMarks, passingMarks |
| Errors | 400; 429; 5xx (no subject → []) |
| Client | S1 header context; S2 single-row pick; S5 aggregation input |
E7 — Publish exam results (S5)
| Endpoint | POST /api/v1/examinations/:id/publish (examination.controller.ts:54-56) |
| Behaviour | stamps publishedAt on all results of all subjects (examination.service.ts:203-207, examination-result.repository.ts:42-51), sets exam status: 'published' (:209-211), emits ExamResultsPublished (:212-219) → in-app queue / results-published job (event-queue-map.ts:27) |
| Response | 2xx envelope, data null; exam with zero subjects → still publishes (subjectIds.length guard, :206) |
| Errors | 404 (exam missing via subjectRepo/examRepo); 429; 5xx |
| Notes | No unpublish endpoint; repeat publish is idempotent (re-stamps publishedAt) |
| Blueprint | Results.md:37 names event ResultPublished — code emits ExamResultsPublished (naming mismatch, flag for backend alignment) |
E8 — List examinations (S1/S5 entry navigation) — paginated
| Endpoint | GET /api/v1/examinations?page&limit&sort&q (examination.controller.ts:30-32) |
| Params | page ≥1 default 1; limit 1–100 default 20; sort (-field desc); q (pagination-query.dto.ts:5-30) |
| Response | 200 envelope, data + meta {page,limit,totalItems,totalPages,hasNext,hasPrevious} (pagination-query.dto.ts:32-39) |
| Client | exam picker for S1/S5; infinite scroll while hasNext |
E9 — (planned) bulk marks import
| Endpoint | POST /api/v1/examinations/:id/marks-import — spreadsheet import, validation + rollback (IMPLEMENTATION_PLAN.md:219) (planned) |
| Client | S1 "Import" action; async job progress (planned) |
E10 — (proposed) analytics
| Endpoint | GET /api/v1/results/analytics (blueprint Results.md:28) — not implemented (proposed) |
| Client | S5 today = client-side aggregation of E2 + E6 |
E11 — (planned) rank
| Fact | Blueprint Results.md:54 "Rank computed per class per exam"; no code exists (planned) |
| Client | S4 rank slot reserved; hidden until shipped |
Event side-effect map
| Event | Queue / job | Source |
|---|---|---|
MarksEntered | none routed (audit-write (planned)) | examination.service.ts:163-173 |
ExaminationResultCreated | none routed (emitted by ResultService.create, result.service.ts:114-126 — path unused by controller) | result.service.ts:114-126 |
ExamResultsPublished | in-app / results-published | event-queue-map.ts:27 |
13 — State Management (Results Module)
- 1. Cubit map
- 2. Repository layer
- 3. MarksEntryCubit (S1) — the only write state machine
- 4. StudentResultsCubit (S3)
- 5. ReportCardCubit (S4)
- 6. GradeSummaryCubit (S5)
- 7. Realtime
- 8. Offline queue (marks)
Cubits per 00-shared/06_State_Management.md (Bloc; repository layer on top of the v1 API; no server-side state beyond the documents). All reads are cacheable GETs; the only writes are idempotent upserts, so state logic is simple.
1. Cubit map
| Cubit | Screen | Data source |
|---|---|---|
ExamListCubit | exam picker (S1/S5 entry) | GET /api/v1/examinations (paginated, E8) |
SubjectDetailCubit | S2 | GET /api/v1/examinations/:id/subjects (E6) |
MarksEntryCubit | S1 | GET /api/v1/results/exam-subject/:id (E2) + POST …/marks (E3) |
StudentResultsCubit | S3 | GET /api/v1/results/student/:id (E1) + GET /api/v1/examinations/:id (E5) per group |
ReportCardCubit | S4 | GET /api/v1/results/report-card/:studentId/:examId (E4) |
GradeSummaryCubit | S5 | E6 + N×E2 (client aggregation) + POST /examinations/:id/publish (E7) |
2. Repository layer
ResultsRepository— wraps E1–E4; exposesfetchStudentResults,fetchExamSubjectResults,enterMarks,fetchReportCard.ExaminationsRepository— wraps E5–E8.- Cache keys (00-shared/06 §3):
sl:{tenant}:results:student:{studentId},sl:{tenant}:results:subject:{examSubjectId},sl:{tenant}:report-card:{studentId}:{examId},sl:{tenant}:exams:{page}. - Volatile TTL 5 min; report card is the only hard-invalidate candidate —
invalidate on
results-publishedin-app event.
3. MarksEntryCubit (S1) — the only write state machine
sealed class MarksEntryState
├── MarksEntryLoading
├── MarksEntryLoaded(rows, coverage, header) // row = {studentId, marks?, grade?, remarks?, publishedAt?, dirty, saving, error}
├── MarksEntryDirty(rows, pendingCount) // ≥1 row dirty
├── MarksEntrySavingRow(rowId) // per-row upsert in flight
├── MarksEntryRowError(rowId, message, requestId) // failed save, row stays dirty
└── MarksEntryOffline(rows, unsyncedCount) // draft queued locally
load(examSubjectId)→ E2; maps rows; sorts by student name client-side.commitRow(studentId, {marksObtained, grade, remarks})→ validates≤ maximumMarkslocally, setsdirty, fires upsert; on success clears dirty; on 404-over-max keeps dirty +MarksEntryRowError.commitAll()→ replays dirty rows in order (each its own POST, E3).syncOfflineDraft()→ replays the local draft queue (15_Flutter §Offline).setPublished(rowId)→ locks row UI on in-appresults-publishedevent.
stateDiagram-v2
[*] --> Loading
Loading --> Loaded: rows fetched (E2)
Loaded --> Dirty: commitRow (local validation ok)
Dirty --> SavingRow: POST marks (E3)
SavingRow --> Loaded: 2xx (row clean)
SavingRow --> RowError: 404 over-max / network
RowError --> Dirty: user edits again
Dirty --> Offline: connectivity lost
Offline --> SavingRow: syncOfflineDraft replay
Loaded --> Loading: pull-to-refresh
RowError --> Loaded: discarded by user
4. StudentResultsCubit (S3)
S3State: Loading | Loaded(groups) | Empty | Error(code)
load(studentId)→ E1; for each distinctexaminationSubjectId→ E5 (group); E5 404 → orphan row dropped (exam soft-deleted), rest grouped.setFilter(All | Published | Unpublished | grade)— pure client filter, no refetch.
5. ReportCardCubit (S4)
S4State: Loading | Ready(reportCard) | NoSubjects | NotFound | Error(code)
load(studentId, examId)→ E4; mapssubjectName(raw subject ID,result.service.ts:75) → display names via local subject registry.- 404 with message "No subjects found for this examination." (
result.service.ts:51-52) →NoSubjects(guided empty state), other 404s →NotFound.
6. GradeSummaryCubit (S5)
load(examId)→ E6, then N×E2 (parallel, capped concurrency 4); emitsAggregating(progress)per resolved subject →Ready(summary).summary= per-subject coverage + grade distribution (counts of storedgradestrings; ungraded bucket) + pass/fail vspassingMarks.publish()→ E7; success → re-load+ invalidate student result caches;RateLimited(seconds)state on 429.
7. Realtime
- In-app stream (
00-shared/06 §4) consumesresults-published(job name perevent-queue-map.ts:27): MarksEntryCubit locks published rows, ReportCardCubit refreshes, GradeSummaryCubit disables publish. - No per-keystroke network; local-only.
8. Offline queue (marks)
- Draft table keyed
(examSubjectId, studentId); rows carry fullEnterMarksDtopayload + monotonic sequence; replay on reconnect in sequence order; on per-row failure keep row + surface error (B8). - Queue is not a queue manager — plain ordered list, replayed by
syncOfflineDraft()(ponytail: no queue package).
14 — QA Checklist (Results Module)
- 1. Envelope & contract conformance
- 2. Publish behaviour & immutability
- 3. Missing-marks handling
- 4. Grade bands (exact boundaries)
- 5. Marks entry validation (404-vs-422 quirk)
- 6. Concurrency & idempotency
- 7. Data quirks (client workarounds)
- 8. Accessibility & responsive
- 9. Performance
- 10. Regression baseline
Acceptance criteria against real server behaviour. Every item cites the code it guards. Reference 00-shared/10_QA_Baseline.md for general baseline + envelope conformance.
1. Envelope & contract conformance
-
Every result endpoint returns the v1 envelope (00-shared/07 §2-3): success
{success,message:"OK",data,meta?,timestamp,requestId}, error witherror.codefrom the allowed set. -
GET /api/v1/results/student/:idandexam-subject/:idreturn arrays indatawith nometa— pagination does not exist on result routes (examination-result.repository.ts:20-28); the client must not page. -
401 without a Bearer token on all result + exam routes (
JwtAuthGuard,result.controller.ts:10).
2. Publish behaviour & immutability
-
POST /api/v1/examinations/:id/publishstampspublishedAton every result row of every subject (examination.service.ts:203-207) and sets examstatus: 'published'(:209-211). -
Publish with zero subjects still succeeds (
subjectIds.lengthguard,:206) — UI must handle "published with no subjects". -
Repeat publish is idempotent — re-stamping
publishedAtmust not error or duplicate rows (examination-result.repository.ts:42-51). - No unpublish endpoint exists — QA confirms client never offers revert.
-
(planned)immutability: after publish,enterMarksstill updates rows (examination.service.ts:139-195has nopublishedAtcheck). Client lock is UI-only; flag as known gap, revisit when server enforces. -
ExamResultsPublishedfires exactly once per publish (:212-219) →in-app/results-published(event-queue-map.ts:27); in-app job is idempotent (worker test).
3. Missing-marks handling
-
Report card with a student missing some subjects:
marksObtained: 0for missing rows (result.service.ts:70), totals still include that subject's maximum (:72),percentageandoverallGradecomputed on the 0-fill (:83-95). -
Data limitation: a real "0" and "not entered" are indistinguishable
(
result.service.ts:70) — UI distinguishes via presence of a row in E1/E2; QA documents this in release notes. -
Student with zero result rows: E1 returns
[](no error); E4 returns 404 only when the exam has no subjects (result.service.ts:51-52).
4. Grade bands (exact boundaries)
-
computeGradeboundaries verified againstresult.service.ts:130-138:90→A+,89.99→A,80→A,79.99→B+,70→B+,69.99→B,60→B,59.99→C,50→C,49.99→D,40→D,39.99→F,0→F. -
Percentage is rounded to 2 decimals (
result.service.ts:83-86) before banding — test 89.995% (rounds to 90 → A+). -
Per-subject
gradeis client-supplied and stored verbatim (examination-subject.dto.ts:57-60,examination.service.ts:157) — server does not compute per-subject grades; QA asserts UI never claims server-computed per-subject grades.
5. Marks entry validation (404-vs-422 quirk)
-
marksObtained > maximumMarks→ server returns 404RESOURCE_NOT_FOUND(examination.service.ts:145-146), not 422BUSINESS_RULE_VIOLATION. Client normalises to "marks cannot exceed maximum" (08/09); QA asserts the user-facing message is correct despite the misleading code. -
Negative marks → 400
VALIDATION_ERROR(Min(0),examination-subject.dto.ts:54). -
Unknown
examSubjectId→ 404 (examination.service.ts:143-144). - Malformed Mongo id → 400.
6. Concurrency & idempotency
-
Two clients upsert the same
(studentId, examinationSubjectId)— service-level check + unique index{tenantId, studentId, examinationSubjectId}(examination-result.schema.ts:31-33) → last-write-wins; noDUPLICATE_RESOURCE(409) is expected on sequential writes. -
True race (both find nothing, both create): one may hit Mongo dup-key
(11000) → surfaces as 5xx; QA verifies the client treats it as "retry — row now exists" and re-reads. Backend fix
(planned): catch 11000 → read existing. - Re-submitting the same payload (offline replay) → identical end state, one row.
-
Cross-tenant id on any path → 404/
[](scoped repo), never another tenant's data.
7. Data quirks (client workarounds)
-
subjectName= raw subject ID (result.service.ts:75) — UI shows mapped names; QA checks no raw IDs leak to screens. - S3 orphan rows (exam soft-deleted → E5 404) are dropped without crash.
-
createdAt/updatedAt/versionpresent on result rows (base.schema.ts); UI may useupdatedAtfor "last saved" display.
8. Accessibility & responsive
- Per-screen a11y checklist (06_Screen_Specifications.md §8 each): table semantics, grade announced as letters ("A plus"), contrast ≥ 4.5:1 on chips (09_Accessibility_Baseline.md §2), charts have text equivalents.
- Responsive breakpoints per 11_Design_System_Mapping.md §6: <600 / 600-719 / ≥720; save bar safe-area on portrait phones.
9. Performance
- S1 grid stays interactive at 200+ students (virtualised — 15_Flutter §Table).
- S5 aggregation of N subjects: ≤ 4 parallel fetches, skeleton progress (13 §6).
- Offline draft replay: 100 rows → ordered, per-row success/failure, no deadlock on partial failure.
10. Regression baseline
- All reads unchanged after publish (publishedAt stamp must not alter E1/E2/E4 shapes).
-
Soft-deleted exam subject → E2
[], marks POST 404, no crash.
15 — Flutter Implementation Guide (Results Module)
- 1. Marks-entry grid performance (S1)
- 2. Offline marks draft
- 3. Report card rendering & cache (S4)
- 4. Grade summary charts (S5)
- 5. Networking & error mapping
- 6. Realtime
- 7. Suggested file layout
- 8. Golden/unit test hooks
Client implementation notes against the v1 API. Stack per 00-shared/11_Flutter_App_Architecture.md; state per 13_State_Management.md; components per 07_Component_Library.md.
(forward-looking)— mobile is out of Phase 1 (see 01_Product_Overview.md).
1. Marks-entry grid performance (S1)
- Never use stock
DataTablefor the entry grid: it builds every cell eagerly. Use a virtualised list:ListView.builderwith fixed row extent (itemExtentorprototypeItem) so off-screen rows are not built.- Keep row height constant; varying remarks text wraps inside a fixed two-line box
(ellipsis) — never
IntrinsicHeight.
- Only visible rows own
TextEditingControllers — create controllers initemBuilderand dispose on scroll-out; a 300-student grid otherwise leaks controllers and rebuilds everything per keystroke. - Local edit buffer:
TextEditingController.textis the only mutable state (ponytail: no per-cell form model). Commit on Enter/onBlur viaMarksEntryCubit.commitRow(...)(13 §3); the cubit owns dirty/saving state. - Numeric input:
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$'))(decimal allowed —IsNumberhas no integer constraint,examination-subject.dto.ts:53),FontFeature.tabularFigures(),monotype (11 §3). - Client validation mirrors the server rule before any POST:
marksObtained > maximumMarks → inline error, no request(examination.service.ts:145-146). - Debounce nothing; commit is user-initiated (Enter/blur), so per-row saving
state stays sparse. Batch saves (
commitAll) fire sequentially, one in flight per row, others queued — noFuture.waiton the whole grid (rows must stay independent).
2. Offline marks draft
- Local store:
drift/sqflitetablemarks_draft(exam_subject_id, student_id, marks_obtained, grade, remarks, seq INTEGER PRIMARY KEY AUTOINCREMENT)— one row per unsynced edit,seqpreserves entry order (13 §8). - Upsert semantics make replay safe: re-POSTing the same
EnterMarksDtois an update, never a duplicate (examination.service.ts:147-175). No dedupe keys needed. - Replay: on connectivity restore, drain in
seqorder viaMarksEntryCubit.syncOfflineDraft(); per-row failure → keep the row, surface error, continue (09 B8). Row is deleted from the draft only on 2xx. - Before overwriting a draft row, merge by
updatedAtfrom E2 fetch: if the server row is newer than the draft, keep the server value and drop the draft (server wins — last-write-wins on the server,examination.service.ts:152-161). - Conflict policy is deliberately naive (no three-way merge):
(planned)revisit when multi-device grading lands.
3. Report card rendering & cache (S4)
- Cache
GET /api/v1/results/report-card/:studentId/:examIdkeyed(tenant, studentId, examId)TTL 5 min (13 §2); invalidate onresults-publishedin-app event so a published exam's card refreshes immediately (10 I4). - Render server-computed values verbatim —
percentage,overallGrade,generatedAtcome fromresult.service.ts:83-96; the client never recomputes (single source of truth). subjectNameis the raw subject ID (result.service.ts:75) — resolve via a local subject registry (name map fromGET /api/v1/subjects,(planned)-friendly: graceful fallback shows the ID if the registry misses).- 404 with message "No subjects found for this examination."
(
result.service.ts:51-52) →NoSubjectsguided empty state, not an error screen.
4. Grade summary charts (S5)
- No chart package.
R-GradeDistributionChart=ColumnofRow(children: [label, Expanded(FractionallySizedBox(widthFactor: n/total, child: container)), countText])— a dozen lines, semantic labels free. - Counts come from stored
gradestrings (client-supplied;R-GradeChipcolour mapping per 11 §1); ungraded bucket = rows withoutgrade. - Always render the numeric counts as text next to bars (a11y, 06 §S5).
5. Networking & error mapping
- Single
ApiClientwrapper per 00-shared/07 + 11_Flutter_App_Architecture.md. - Map envelope codes → copy:
| Code | UI |
|---|---|
400 VALIDATION_ERROR | inline field errors |
401 UNAUTHENTICATED | force re-login |
403 PERMISSION_DENIED | error screen (today unreachable — no RBAC on routes) |
404 RESOURCE_NOT_FOUND | over-max normalisation OR empty/not-found states (distinguish by route) |
429 RATE_LIMITED | backoff + countdown (S5 publish) |
| 5xx | generic + requestId in snackbar (≥ 4 s) |
- Timeout: marks POST 15 s; reads 10 s. Retry policy: reads ×2 exponential; writes: no auto-retry — user-invoked or offline-draft replay only.
6. Realtime
results-publishedin-app event (event-queue-map.ts:27) →SocketListenerroutes toReportCardCubit.refresh()andMarksEntryCubit.setPublished(rowId)(10 I4). No other realtime surface exists for this module.
7. Suggested file layout
lib/features/results/
├── data/
│ ├── results_repository.dart # E1-E4 (13 §2)
│ └── examinations_repository.dart # E5-E8
├── cubits/ # per 13 §1
│ ├── marks_entry_cubit.dart
│ ├── student_results_cubit.dart
│ ├── report_card_cubit.dart
│ └── grade_summary_cubit.dart
├── screens/ # S1-S5
│ ├── marks_entry_screen.dart
│ ├── exam_subject_detail_screen.dart
│ ├── student_results_screen.dart
│ ├── report_card_screen.dart
│ └── grade_summary_screen.dart
├── widgets/ # 07_Component_Library.md
│ ├── data_table.dart # R-DataTable
│ ├── grade_chip.dart # R-GradeChip
│ ├── marks_field.dart # R-MarksField
│ ├── coverage_bar.dart # R-CoverageBar
│ ├── grade_distribution_chart.dart # R-GradeDistributionChart
│ └── publish_panel.dart # R-PublishPanel
└── offline/
├── marks_draft_store.dart # §2
└── draft_replayer.dart
8. Golden/unit test hooks
computeGradeband edges are server-side — unit-test the client display mapping (11 §1) at90/89.99/80/…againstresult.service.ts:130-138(14 §4).MarksEntryCubitstates viabloc_test(13 §3 state machine).DraftReplayer: offline queue order + partial-failure continuation.
01 — Product Overview (Rooms Module)
- 1. Purpose
- 2. Business goals
- 3. User goals
- 4. Stakeholders
- 5. Why this exists
- 6. Dependencies (module + platform)
- 7. Success metrics
- 8. Edge cases
- 9. Assumptions
- 10. Assumptions & Open Questions
- 11. Glossary
StudyLyon — multi-tenant ERP / School Management API. This package designs the Rooms module client (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, and wire contracts are derived directly from
src/modules/rooms/**,src/modules/rbac/permissions.constants.ts,src/database/base.schema.ts+base.repository.ts, andsrc/common/dto/pagination-query.dto.ts. No feature is invented; gaps are flagged in §10 and the module Assumptions & Open Questions.
1. Purpose
Rooms is the physical-facility registry of an institution: a tenant-scoped catalog of
classrooms, labs, libraries, offices, halls, and other spaces. It is the reference data
that future scheduling surfaces (timetable, bookings) will hang off — a room is only
bookable if it exists as a document in rooms.
| Responsibility | Source |
|---|---|
| Room CRUD (name, code, capacity, type, building, facilities) | rooms.controller.ts:24-52; rooms.service.ts:18-51 |
| Tenant-scoped storage + soft-delete filtering | room.repository.ts:9-15; base.repository.ts:20-30 |
| Room code uniqueness per tenant (create-time) | rooms.service.ts:19-21; room.schema.ts:38 |
| Room type taxonomy | room.schema.ts:7-14 (classroom/lab/library/office/hall/other) |
| Paginated list contract | rooms.service.ts:29-33; pagination-query.dto.ts:32-54 |
RBAC permissions rooms.read/create/update/delete (declared, not enforced server-side) | permissions.constants.ts:50-53 |
Soft delete (logical, isDeleted + deletedAt, never hard) | rooms.service.ts:48-51; base.repository.ts:68-74 |
2. Business goals
| Goal | Measure | Source |
|---|---|---|
| Every bookable/schedulable space is catalogued once per tenant | one rooms document per physical room; unique (tenantId, code) | room.schema.ts:38 |
| No cross-tenant data leakage | repository injects tenantId + isDeleted:false on every query | base.repository.ts:20-30 |
| Duplicate codes fail fast with a clear message | 409 ConflictException "Room code ... already exists." | rooms.service.ts:19-21 |
| Deletion is reversible | soft delete only; list/detail never return deleted docs | rooms.service.ts:48-51 |
| Safe growth toward timetable/bookings | rooms are the reference entity; bookings marked (planned) below | §9 |
3. User goals
- Org Admin / Admin Staff: create, edit, filter, and retire the room catalog; keep codes, types, capacities, buildings, and facilities accurate.
- Timetable Coordinator (admin staff): browse rooms by type/building/capacity to plan
where classes run (timetable module
(planned)—docs/IMPLEMENTATION_PLAN.md:227). - Teacher / staff: look up a room (location, capacity, facilities) before/while using it.
- Platform Super Admin: cross-tenant visibility only via platform tooling; never operates inside a tenant's room data.
4. Stakeholders
Org Admins, admin staff (timetable coordinator, office staff), teachers, students/parents (via future booking/QR surfaces), facility/estate management, platform ops (tenant isolation), engineering/design/QA consuming these docs.
5. Why this exists
Every class, exam, and (future) booking needs a physical space. Without a room registry,
timetable entries would reference free-text room names — no capacity check, no duplicate
detection, no facility matching. Rooms is the smallest reference module that makes
timetable ((planned)), bookings ((planned)), and exam seating meaningful.
6. Dependencies (module + platform)
| Dependency | Role | Status |
|---|---|---|
Auth (JwtAuthGuard) | every endpoint requires Bearer JWT | implemented (rooms.controller.ts:19) |
| RBAC | rooms.* permissions declared in ALL_PERMISSIONS | declared (permissions.constants.ts:50-53); guard enforcement (planned) — see OQ-2 |
Tenant context (TenantContextService) | tenant scoping via BaseRepository | implemented (base.repository.ts:20-30) |
Mongoose (collection rooms) | persistence, unique index | implemented (rooms.module.ts:13; room.schema.ts:38) |
| Swagger | @ApiTags('rooms'), @ApiOperation, DTO @ApiProperty* | implemented (rooms.controller.ts:12,17,25; create-room.dto.ts:12-38) |
| Timetable module | consumes rooms as venue reference | (planned) — IMPLEMENTATION_PLAN.md:227 |
| Bookings module | room availability, check-in | (planned) — not yet in IMPLEMENTATION_PLAN.md |
| QR room signage / check-in | scan room code → room detail | (forward-looking) |
7. Success metrics
- Zero duplicate
(tenantId, code)pairs reach the DB (create check + unique index). - Room list p95 < 300 ms (paginated find + count).
- Soft-deleted rooms never appear in list/detail (repository scope).
- Delete of an unknown id returns 404, never 500.
- Cross-tenant id access returns 404 (scoped filter), no existence leak.
8. Edge cases
| Edge case | Behaviour | Source |
|---|---|---|
| Duplicate room code on create | 409 ConflictException Room code "X" already exists. | rooms.service.ts:19-21 |
| Duplicate room code on update | No service check — $set runs; unique index {tenantId, code} may raise Mongo 11000 → 500. Gap flagged OQ-1 | rooms.service.ts:42-46; room.schema.ts:38 |
| PATCH with partial body | Controller reuses CreateRoomDto (rooms.controller.ts:44) — name/code are required even on PATCH; missing → 400 VALIDATION_ERROR | rooms.controller.ts:44; create-room.dto.ts:12-18 |
capacity <= 0 or non-integer | No @Min/@IsInt — only @IsNumber; negative/fractional accepted. Client validation (proposed) | create-room.dto.ts:20-23 |
type outside enum | 400 VALIDATION_ERROR (@IsEnum) | create-room.dto.ts:25-28 |
Invalid ObjectId in :id | CastError → 400 VALIDATION_ERROR "Invalid resource identifier." | http-exception.filter.ts (shared) |
| Unknown or soft-deleted id | 404 RESOURCE_NOT_FOUND (NotFoundException rooms.service.ts:38,44,50) | rooms.service.ts:36-51 |
| Delete of a room referenced by timetable/bookings | No in-use guard today — deletes succeed; consumers must handle missing venue. Guard (planned) | rooms.service.ts:48-51 |
| Cross-tenant id access | scoped filter → 404, never leak | base.repository.ts:24-29 |
9. Assumptions
- Client scope flag (matches 00-shared/01 §9): native mobile apps are out of Phase 1
(
PRODUCT_REQUIREMENTS_DOCUMENT.md:144); these docs are forward-looking full client specs against the implemented API. Module-specific(forward-looking)marks apply to QR and room check-in surfaces. - Rooms surface is admin-owned (
rooms.create/update/delete), read is broader (rooms.read); timetable coordinators and teachers read the catalog. (planned)items: bookings module, RBAC guard enforcement, in-use delete guard, server-side capacity bounds, update-path duplicate check — flagged in §10.(forward-looking): QR room signage, room check-in/check-out, availability calendar.(proposed): analytics events (see 05/09) and client-side validation rules — the backend DTOs impose no min/max oncapacity, no length limits onname/code.- The wire contract (envelope, pagination meta, error codes) follows 00-shared/07.
10. Assumptions & Open Questions
| # | Item | Status / Impact |
|---|---|---|
| OQ-1 | Update path has no duplicate-code check. update() blindly $sets (rooms.service.ts:42-46); changing code to an existing one hits the unique index (room.schema.ts:38) → Mongo E11000 → generic 500. Pre-check + 409 (planned). | Room editor (code change) |
| OQ-2 | RBAC not enforced on the controller. Only JwtAuthGuard (rooms.controller.ts:19); rooms.* perms (permissions.constants.ts:50-53) are declared but no @Permissions guard. Client must gate UI; server guard (planned). | All screens |
| OQ-3 | List has no sort/filter/search. findAll accepts only page/limit (rooms.controller.ts:32); PaginationQueryDto's sort/q (pagination-query.dto.ts:21-29) are unused. Default order is Mongo natural (_id) order — no -createdAt sort. Filters (planned). | Room list |
| OQ-4 | No in-use delete guard. remove() soft-deletes regardless of timetable/bookings references (rooms.service.ts:48-51). Guard on reference counts (planned) with bookings/timetable modules. | Delete dialog |
| OQ-5 | No status (operational), floor, or equipment fields — schema is name/code/capacity/type/building/facilities (room.schema.ts:18-34); facilities: string[] is the closest to equipment. Additions (proposed)/(planned); the client must not invent fields the API won't return. | Room model |
| OQ-6 | No update-room.dto.ts. PATCH reuses CreateRoomDto (rooms.controller.ts:44) so name+code are mandatory on every update. A true partial DTO (planned). | Room editor |
| OQ-7 | Capacity unvalidated — @IsNumber only (create-room.dto.ts:20-23); negative/0/fractional capacities persist. Server @Min(1) (planned); client blocks < 1 (proposed) today. | Room form |
| OQ-8 | Delete returns no payload — remove() is Promise<void> (rooms.service.ts:48); client reconciles locally. | Delete dialog |
11. Glossary
| Term | Meaning | Source |
|---|---|---|
| Room | Tenant-scoped physical space document (rooms collection) | room.schema.ts:16-35 |
| Room code | Short unique-per-tenant identifier (e.g. LAB-02, A-101) | room.schema.ts:21-22,38 |
| Room type | classroom/lab/library/office/hall/other | room.schema.ts:7-14 |
| Facilities | Free-text feature tags (projector, AC, smartboard …) | room.schema.ts:33-34 |
| Soft delete | isDeleted:true + deletedAt; excluded from all queries | base.schema.ts:19-24; base.repository.ts:20-30 |
| Envelope | {success,message,data,meta?,timestamp,requestId} | 07_API_Conventions.md §2-3 |
| Permission | rooms.read / rooms.create / rooms.update / rooms.delete | permissions.constants.ts:50-53 |
02 — User Personas (Rooms Module)
- 1. Persona map
- 2. Org Admin — "Meera"
- 3. Office / Timetable Coordinator — "Ravi"
- 4. Teacher — "Ananya"
- 5. Platform Super Admin — "DevOps Dan"
- 6. Student / parent — "Kabir & mother"
(planned)surface - 7. Persona → feature priorities
Who uses the Rooms module, what they need, what permissions map to them, and where the current backend constrains them. Personas derive from the module's API surface (
src/modules/rooms/**) and the declared RBAC permissions (permissions.constants.ts:50-53).
1. Persona map
| Persona | Primary actions | Rooms perms | Platform |
|---|---|---|---|
| Org Admin | create/edit/delete rooms, resolve duplicates | rooms.read/create/update/delete | web-first, tablet |
| Office / Timetable Coordinator | browse + filter catalog, spot rooms by capacity/facilities | rooms.read (+ update (proposed)) | desktop, tablet |
| Teacher | look up room info before class | rooms.read | mobile, tablet |
| Platform Super Admin | tenant-agnostic support/audit | platform role (never inside tenant) | web |
| Student / parent | check venue/QR for a booked session | rooms.read via booking surface (planned) | mobile |
Facility manager (proposed) | operational status, maintenance flags | rooms.update (status field (proposed) — OQ-5) | mobile, web |
2. Org Admin — "Meera"
- Goals: keep the room catalog accurate: add new labs at term start, rename/merge rooms,
retire rooms no longer used. Codes must be unique and sensible (
LAB-02,A-101). - Friction today: duplicate-code guard exists at create (409,
rooms.service.ts:19-21) but not on update (OQ-1 — changing a code onto an existing one 500s); no delete in-use guard (OQ-4); nostatus/floorfields to model an out-of-service room (OQ-5). - Screens: Room Editor (
/rooms/new,/rooms/:id/edit), Room List (/rooms). - Out of scope guardrails: client must gate create/update/delete UI to
rooms.create/update/deleteuntil the server RBAC guard lands (OQ-2).
3. Office / Timetable Coordinator — "Ravi"
- Goals: during timetable planning (
(planned)—IMPLEMENTATION_PLAN.md:227) assign classes to rooms that fit: right type, enough capacity, right building. - Needs from Rooms: filterable list (type, building, capacity) — filter/sort not yet
on the API (OQ-3,
rooms.controller.ts:32); client-side filtering(proposed)until then; capacity shown on every card. - Friction today: pagination only (
page/limit); list is Mongo_idorder, no-createdAtdefault sort; no search (q).
4. Teacher — "Ananya"
- Goals: quickly confirm "where is the lab and does it have a projector?" before class.
- Needs: room detail with building + facilities; room card on schedule entries
(timetable
(planned)). - Friction today: read-only surface is fine; facility tags are free text, so data hygiene depends on the admin.
5. Platform Super Admin — "DevOps Dan"
- Goals: support tickets ("room missing"), verify tenant isolation, audit deletions.
- Needs: soft-delete audit trail (
deletedAt/deletedBy—base.schema.ts:23-27), structural impossibility of cross-tenant reads (base.repository.ts:24-29). - Friction today: no audit-log module surface for rooms (audit
(planned)inIMPLEMENTATION_PLAN.mdPhase 5 §"Permissions audit"); the platform admin bypasses tenant scope intentionally (base.repository.ts:21-23).
6. Student / parent — "Kabir & mother" (planned) surface
- Goal (forward-looking): check-in via QR at the venue, see room info on the booking receipt.
- Today: no student-facing room surface; bookings module
(planned), QR(forward-looking).
7. Persona → feature priorities
| Priority | Feature | Personas served |
|---|---|---|
| P0 | Room CRUD with 409 duplicate guard | Admin |
| P0 | Paginated list + detail | All |
| P1 | Duplicate check on update (OQ-1) | Admin |
| P1 | Filter/sort/search on list (OQ-3) | Coordinator |
| P1 | Client permission gating (OQ-2) | All |
| P2 | Capacity bounds (OQ-7) | Coordinator |
| P2 | In-use delete guard (OQ-4) | Admin, coordinator |
| P3 | Status/floor/equipment fields (OQ-5) | Facility mgr |
| P3 | QR signage + check-in | Student, staff (forward-looking) |
03 — User Journeys (Rooms Module)
- 1. Journey: Create a room (Org Admin)
- 2. Journey: Find a room for timetable planning (Coordinator)
- 3. Journey: Edit a room — code conflict (Admin)
- 4. Journey: Delete a room (Admin)
- 5. Journey: Book a room
(planned)— forward-looking - 6. Journey: Offline / error handling (all)
End-to-end journeys through the Rooms module, each step mapped to the implemented API (
src/modules/rooms/**). Markings:(planned)= not in code yet,(forward-looking)= future surface,(proposed)= client-side contract.
1. Journey: Create a room (Org Admin)
| # | Step | Screen | API / source |
|---|---|---|---|
| 1 | Admin opens Room List (/rooms), taps FAB "New room" | Room Editor (create) | — |
| 2 | Fills name, code, capacity, type, building, facilities | Room Editor | CreateRoomDto fields (create-room.dto.ts:11-38) |
| 3 | Client validates: required name/code, capacity ≥ 1 (proposed) | Room Editor | server: @IsString/@IsNumber only (OQ-7) |
| 4 | Submits → POST /api/v1/rooms | Room Editor (loading) | rooms.controller.ts:24-28 |
| 5a | 201 → navigates to Room Detail, snackbar "Room created" | Room Detail | rooms.service.ts:18-23 |
| 5b | 409 duplicate code → inline error under code field, form kept | Room Editor | rooms.service.ts:19-21 |
| 5c | 400 → field errors mapped from envelope details[] | Room Editor | 00-shared/07 |
2. Journey: Find a room for timetable planning (Coordinator)
| # | Step | Screen | API / source |
|---|---|---|---|
| 1 | Opens /rooms | Room List | GET /api/v1/rooms?page=1&limit=20 (rooms.controller.ts:30-34) |
| 2 | Filters by type/building/capacity — client-side only today (proposed); server filter (planned) (OQ-3) | Room List (filter chips) | no query params in code |
| 3 | Scans cards for capacity badge; scrolls → hasNext → page+1 | Room List (infinite scroll) | pagination-query.dto.ts:32-39 |
| 4 | Taps room → detail with facilities list | Room Detail | GET /api/v1/rooms/:id (rooms.controller.ts:36-40) |
| 5 | Adds room to timetable entry | Timetable module (planned) | IMPLEMENTATION_PLAN.md:227 |
3. Journey: Edit a room — code conflict (Admin)
| # | Step | Screen | API / source |
|---|---|---|---|
| 1 | Opens /:id/edit, changes code to one that exists | Room Editor (edit) | PATCH rooms.controller.ts:42-46 |
| 2 | Gap: no 409 — $set runs, unique index raises E11000 → 500 (OQ-1) | Room Editor (error) | rooms.service.ts:42-46; room.schema.ts:38 |
| 3 | Server fix (planned): pre-check code → 409 "already exists", inline field error | Room Editor | — |
| 4 | Client today (proposed): block submit on duplicate-code async check, show inline hint | Room Editor | — |
| 5 | Success → snackbar + detail refreshed (version incremented, base.repository.ts:57-66) | Room Detail | — |
4. Journey: Delete a room (Admin)
| # | Step | Screen | API / source |
|---|---|---|---|
| 1 | Room Detail → menu → "Delete room" | Confirm dialog | — |
| 2 | Typed/confirm → DELETE /api/v1/rooms/:id | dialog (loading) | rooms.controller.ts:48-52 |
| 3a | 200 → row removed locally, snackbar "Room deleted" (soft delete) | Room List | rooms.service.ts:48-51; base.repository.ts:68-74 |
| 3b | 404 → snackbar "Room not found" (already deleted) | Room List | rooms.service.ts:50 |
| 4 | Gap: no in-use guard — deleting a room referenced by timetable/bookings succeeds silently (OQ-4); warning (planned) | — | rooms.service.ts:48-51 |
| 5 | Audit: deletedAt/deletedBy recorded | — | base.schema.ts:23-27 |
5. Journey: Book a room (planned) — forward-looking
| # | Step | Screen | API / source |
|---|---|---|---|
| 1 | Room Detail → "Book" (button (planned)) | Booking sheet | bookings module (planned) |
| 2 | Pick date/time slot; availability from booking module | Booking sheet | (planned) |
| 3 | Attendee scans QR at venue door → room info + session | QR screen (forward-looking) | (forward-looking) |
| 4 | Analytics (proposed): utilization per room → insights | Analytics (proposed) | (proposed) |
6. Journey: Offline / error handling (all)
- Reads: last-good cache + offline banner; writes blocked with "You're offline" (no module offline queue) — per 00-shared/06 §3.6.
- 401 → refresh → replay → session expiry.
- 403 (future server RBAC) → hide route/redirect to 403 screen (client already gates by
rooms.*perms — OQ-2).
04 — Information Architecture (Rooms Module)
- 1. Placement in the app
- 2. Screen tree
- 3. Domain model (exact from schema)
- 4. Information hierarchy
- 5. Navigation rules
- 6. Related modules
Where Rooms sits in the app, the screen tree, the data model, and how it relates to other modules. Global IA baseline per 00-shared/05; data model derived exactly from
src/modules/rooms/schemas/room.schema.ts.
1. Placement in the app
Rooms is a reference-data module inside the admin workspace: Academics → Timetable → Rooms in the long run (timetable (planned)), but today it stands alone under
Resources / Infrastructure navigation (per 00-shared/05 §2 admin destinations).
Home / Dashboard
└── Admin workspace
├── …other modules…
└── Rooms (/rooms) ← this module
├── Room List /rooms
├── Room Detail /rooms/:id
└── Room Editor /rooms/new · /rooms/:id/edit
2. Screen tree
| Screen | Route | Parent | Permission (client gate) |
|---|---|---|---|
| Room List | /rooms | Admin workspace | rooms.read (permissions.constants.ts:50) |
| Room Detail | /rooms/:id | Room List | rooms.read |
| Room Editor — create | /rooms/new | Room List (FAB) | rooms.create (permissions.constants.ts:51) |
| Room Editor — edit | /rooms/:id/edit | Room Detail | rooms.update (permissions.constants.ts:52) |
| Delete dialog | modal on Detail/List | Room Detail | rooms.delete (permissions.constants.ts:53) |
| Filter sheet | modal on List | Room List | rooms.read |
Server-side RBAC guard is not implemented (only JwtAuthGuard — rooms.controller.ts:19);
the client gates by permission — OQ-2 in 01.
3. Domain model (exact from schema)
Room (collection: rooms — room.schema.ts:16)
├── tenantId string (required) ← from JWT, never from body (base.schema.ts:10-11)
├── name string (required, trim) (room.schema.ts:18-19)
├── code string (required, trim) unique per tenant (room.schema.ts:21-22,38)
├── capacity number (optional) (room.schema.ts:24-25)
├── type enum RoomType default 'classroom' (room.schema.ts:27-28)
│ classroom | lab | library | office | hall | other (room.schema.ts:7-14)
├── building string (optional) (room.schema.ts:30-31)
├── facilities string[] (optional) (room.schema.ts:33-34)
├── createdBy/updatedBy ObjectId (optional) (base.schema.ts:13-17)
├── isDeleted bool default false (base.schema.ts:20-21)
├── deletedAt/deletedBy (soft delete trail) (base.schema.ts:23-27)
├── version number (optimistic lock) (base.schema.ts:30-31)
└── createdAt/updatedAt timestamps (room.schema.ts:16)
Unique index: { tenantId: 1, code: 1 } (room.schema.ts:38)
Not in the model today (gaps — OQ-5): operational status, floor, equipment
(structured), capacity bounds. facilities: string[] is the free-text stand-in.
4. Information hierarchy
- List card: name (title), code (label), type icon + label, capacity badge, building subtitle, facilities preview (first 2 chips + "+n").
- Detail: header (name, type icon, code), meta grid (type, capacity, building),
facilities section, audit footer (created/updated
(proposed)), actions (Edit / Delete — permission-gated). - Editor: single section, ordered: name → code → type → capacity → building → facilities.
5. Navigation rules
- Detail is reachable only from List (no deep-link bookmarks today); deep links
studylyon://rooms/:id(forward-looking). - Editor pops back to Detail (edit) or List (create).
- Delete success pops to List and removes the row locally (
remove()is void —rooms.service.ts:48). - Filter state persists within List session; reset on route pop.
6. Related modules
| Module | Relationship | Status |
|---|---|---|
| Timetable | venue reference for class entries | (planned) — IMPLEMENTATION_PLAN.md:227 |
| Bookings | availability + reservations per room | (planned) — see 01 §9 |
| Exams (seating) | exam room assignment | (planned) — IMPLEMENTATION_PLAN.md:213-220 |
| RBAC | rooms.* permissions | declared (permissions.constants.ts:50-53), enforcement (planned) |
| Organizations | tenant root every room belongs to | implemented |
| Settings | building/floor dictionaries (proposed) | not in code |
05 — Screen Inventory (Rooms Module)
- Legend
- 1. Room List Screen (
/rooms) - 2. Room Detail Screen (
/rooms/:id) - 3. Room Editor — Create (
/rooms/new) and Edit (/rooms/:id/edit) - 4. Delete Room — confirm dialog
- Shared components used
- Analytics events (proposed)
- Keyboard, landscape, tablet, desktop
Every screen of the Rooms module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics.
Legend
States = idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission.
Analytics events follow {module}.{screen}.{action} (proposed; SDK open — 00-shared/10 §8).
1. Room List Screen (/rooms)
| Field | Detail |
|---|---|
| Purpose | Browse, filter, and enter the tenant's room catalog |
| Entry | admin workspace nav, deep link /rooms |
| Exit | tap row → /rooms/:id; FAB → /rooms/new; filter sheet toggle |
| Source | GET /api/v1/rooms?page&limit (rooms.controller.ts:30-34) — pagination only; no sort/filter/q (OQ-3, rooms.controller.ts:32) |
| Widgets | AppAppBar "Rooms" + count badge; AppSearchBar (proposed) (client-side filter until API lands); AppChips type/building filters (proposed); RoomCard list (ListView.builder); AppEmptyState; AppSkeleton; AppOfflineBanner; AppFAB "New room" (gated rooms.create); AppErrorState retry |
| States | loading (skeleton rows), success (cards), empty ("No rooms yet" + create CTA), error (retry), offline (banner + last-good cache), permission (rooms.read false → no nav entry) |
| Pagination | page/limit + meta {hasNext} (pagination-query.dto.ts:32-39); infinite scroll; pull-to-refresh (RefreshIndicator) |
| Permissions | read: rooms.read; create CTA: rooms.create (permissions.constants.ts:50-51) — client-gated (OQ-2) |
| Analytics | rooms.list.open, rooms.list.filter.{type,building}, rooms.list.search, rooms.list.load_more, rooms.list.refresh (proposed) |
| Adaptive | phone: single column + FAB; tablet/desktop ≥840 dp: master-detail split (list + detail pane, 00-shared/05 §3) |
2. Room Detail Screen (/rooms/:id)
| Field | Detail |
|---|---|
| Purpose | Full room profile: meta, facilities, actions |
| Entry | Room List row, deep link studylyon://rooms/:id (forward-looking) |
| Exit | back → List; Edit → /rooms/:id/edit; Delete → confirm dialog → List |
| Source | GET /api/v1/rooms/:id (rooms.controller.ts:36-40); 404 → "Room not found" |
| Widgets | AppCard header (name, type icon, code chip); meta grid (type, capacity, building); AppChips facilities; AppListTile section rows; AppButton Edit / Delete (gated); AppSnackbar on mutations |
| States | loading, success, error(404/offline/network), deleted (row gone → snackbar) |
| Permissions | read rooms.read; Edit rooms.update; Delete rooms.delete (client-gated, OQ-2) |
| Actions | Edit → editor; Delete → typed-confirm dialog (DELETE /api/v1/rooms/:id — rooms.controller.ts:48-52); (planned) Book CTA when bookings module lands |
| Future | QR code card (forward-looking): render room.code as QR for signage; check-in analytics (proposed) |
| Analytics | rooms.detail.open, rooms.detail.edit, rooms.detail.delete.{start,confirm,cancel} (proposed) |
| Adaptive | tablet/desktop: two-column (info + availability (planned)); sheet editor on narrow |
3. Room Editor — Create (/rooms/new) and Edit (/rooms/:id/edit)
| Field | Detail |
|---|---|
| Purpose | Create or update a room document |
| Entry | List FAB (create, gated rooms.create); Detail "Edit" (gated rooms.update) |
| Exit | save → pop (Detail or List) + snackbar; cancel → discard confirm if dirty |
| Source | create: POST /api/v1/rooms (rooms.controller.ts:24-28); edit: PATCH /api/v1/rooms/:id (rooms.controller.ts:42-46) — reuses CreateRoomDto, so name+code required even on PATCH (OQ-6, rooms.controller.ts:44) |
| Form | AppTextField name*, code* (with uniqueness hint), capacity (number, (proposed) min 1 — OQ-7), AppDropdown type (RoomType enum, default classroom), AppTextField building, AppChips facilities input |
| States | idle, validating (async code check (proposed)), submitting (button), success (pop), error: 400 field errors, 409 duplicate code inline (create only today; update (planned) OQ-1), offline block |
| Permissions | rooms.create / rooms.update |
| Analytics | rooms.editor.{create,edit}.{open,submit,success,failure,duplicate} (proposed) |
| Adaptive | phone: single-column scroll + keyboard avoidance; tablet/desktop: centered card ≤ 640 dp |
4. Delete Room — confirm dialog
| Field | Detail |
|---|---|
| Purpose | Confirm soft delete with explicit room name |
| Source | DELETE /api/v1/rooms/:id (rooms.controller.ts:48-52); void response (rooms.service.ts:48) |
| Behaviour | typed-name confirm (matches room.name); loading state on confirm; success → pop to List + snackbar; 404 → "already deleted" snackbar |
| Gap | no in-use guard (OQ-4) — warn copy "room may be referenced by timetables" (planned) server truth |
| Analytics | rooms.delete.confirm, rooms.delete.cancel (proposed) |
Shared components used
AppAppBar, AppSearchBar, AppChips, AppCard, AppListTile, AppTextField,
AppDropdown, AppButton, AppFAB, AppSkeleton, AppEmptyState, AppErrorState,
AppOfflineBanner, AppSnackbar, AppDialog, AppMenu, RefreshIndicator,
AppAvatar/AppIconBadge. Module-specific: RoomCard, RoomTypeIcon, CapacityBadge,
FacilityChips — defined in 07_Component_Library.md.
Analytics events (proposed)
rooms.list.{open,filter,search,load_more,refresh}, rooms.detail.{open,edit,delete},
rooms.editor.{open,submit,success,failure,duplicate}, rooms.delete.{confirm,cancel}.
Future: rooms.booking.{book,cancel} (planned), rooms.checkin.scan (forward-looking),
rooms.analytics.utilization (proposed).
Keyboard, landscape, tablet, desktop
- Editor: portrait-first; landscape scrolls; tablet/desktop constrain width (≤ 640 dp card).
- List: master-detail at ≥ 840 dp; desktop hover highlights rows; filter chips wrap.
- All fields keyboard-avoidance +
ScrollViewresizeToAvoidBottomInset.
06 — Screen Specifications (Rooms Module)
- S1 — Room List (
/rooms) - S2 — Room Detail (
/rooms/:id) - S3 — Room Editor — Create (
/rooms/new) / Edit (/rooms/:id/edit) - S4 — Delete Room — confirm dialog
- 5. Cross-screen rules
Detailed screen-by-screen specifications. Data sources are the exact endpoints from
src/modules/rooms/rooms.controller.ts; fields fromroom.schema.tsandcreate-room.dto.ts. Markings:(planned)backend gap,(proposed)client contract,(forward-looking)future surface. Motion per 00-shared/08, a11y per 00-shared/09.
S1 — Room List (/rooms)
1.1 Purpose & entry
Browse the tenant's rooms; filter; create; open detail. Entry: admin workspace nav
(gated rooms.read), deep link /rooms. Exit: row → S2, FAB → S3-create, back → workspace.
1.2 Layout (phone)
┌─────────────────────────────────┐
│ AppBar "Rooms" [count] │
├─────────────────────────────────┤
│ [Search] (proposed, client) │
│ [Type chips] [Building chips] │
├─────────────────────────────────┤
│ ┌ RoomCard 1 ────────────────┐ │
│ │ [icon] Lab 2 LAB-02 │ │
│ │ Building B · Cap 40 │ │
│ │ projector, AC │ │
│ └─────────────────────────────┘ │
│ ┌ RoomCard 2 … ┐ │
│ └─────────────────────────────┘ │
│ (skeleton rows while loading) │
├─────────────────────────────────┤
│ [FAB +] │
└─────────────────────────────────┘
1.3 Data contract
| Item | Detail | Source |
|---|---|---|
| Endpoint | GET /api/v1/rooms?page=1&limit=20 | rooms.controller.ts:30-34 |
| Response | envelope data: Room[], meta: PaginationMeta {page,limit,totalItems,totalPages,hasNext,hasPrevious} | rooms.service.ts:25-34; pagination-query.dto.ts:32-39 |
| Page/limit | page ≥ 1 default 1; limit default 20 (controller default; shared DTO caps 1–100 — pagination-query.dto.ts:13-19 but not applied on this route) | rooms.controller.ts:32 |
| Sort/filter/search | none — list is Mongo natural order (OQ-3); client-side filter/search (proposed); server params (planned) | rooms.controller.ts:32; rooms.service.ts:29-32 |
1.4 States
| State | UI | Trigger |
|---|---|---|
| Loading | 6 skeleton RoomCards | first load / refresh |
| Success | cards; count badge in AppBar | 200 |
| Empty | AppEmptyState "No rooms yet" + "Add room" (gated) | totalItems = 0 |
| Load-more | bottom spinner row | scroll hits 80% while hasNext |
| Error | AppErrorState + Retry (re-emits LoadFirst) | non-401 failure |
| Offline | AppOfflineBanner + cached list | connectivity lost |
| Permission | no nav entry; route guard → 403 screen | rooms.read absent |
1.5 Interactions
- Row tap → S2 (push; on tablet master-detail select without push).
- Search field (debounce 300 ms
(proposed)): client-side case-insensitive match on name/code/building; resets to page 1. - Type chips (multi-select
(proposed)): client filter ontype; "All" chip resets. - Building chips derived from loaded + cached items
(proposed). - Pull-to-refresh: bypasses cache, reloads page 1.
- Infinite scroll: while
hasNextfetchpage+1, append, dedupe by_id. - Delete row (menu) → S4 dialog; on success remove row + decrement count locally
(
remove()is void —rooms.service.ts:48).
1.6 Analytics (proposed)
rooms.list.open, rooms.list.filter.{type,building}, rooms.list.search,
rooms.list.load_more, rooms.list.refresh, rooms.list.row_tap.
1.7 A11y & motion
- Cards: single semantics label "Lab 2, code LAB-02, capacity 40, building B".
- Loading announced via live region;
m-fastfade-in of cards (00-shared/08). - Focus order: search → chips → cards → FAB.
S2 — Room Detail (/rooms/:id)
2.1 Purpose & entry
Full room profile. Entry: S1 row, deep link studylyon://rooms/:id (forward-looking).
Exit: back; Edit → S3; Delete → S4 → S1.
2.2 Layout
┌───────────────────────────────────┐
│ AppBar ← "Room" [⋮ menu] │
├───────────────────────────────────┤
│ ┌ header card ─────────────────┐ │
│ │ [icon] Lab 2 LAB-02 │ │
│ │ Building B · Cap 40 │ │
│ └──────────────────────────────┘ │
│ Meta grid: │
│ Type: lab Capacity: 40 │
│ Building: B Code: LAB-02 │
│ Facilities: [projector][AC][+2] │
│ (planned: Availability section) │
│ (forward-looking: QR card) │
├───────────────────────────────────┤
│ [Edit] [Delete] (gated) │
└───────────────────────────────────┘
2.3 Data contract
| Item | Detail | Source |
|---|---|---|
| Endpoint | GET /api/v1/rooms/:id | rooms.controller.ts:36-40 |
| Response | envelope data: Room | rooms.service.ts:36-40 |
| 404 | RESOURCE_NOT_FOUND "Room not found." — incl. cross-tenant id (no leak) | rooms.service.ts:38; base.repository.ts:24-29 |
| Bad id | 400 VALIDATION_ERROR (CastError mapping) | shared filter |
2.4 States
| State | UI | Trigger |
|---|---|---|
| Loading | header skeleton | fetch |
| Success | full profile | 200 |
| NotFound | AppEmptyState "Room not found" + back | 404 |
| Error | AppErrorState retry | network/5xx |
| Offline | banner + cached doc | connectivity |
2.5 Interactions
- Edit → S3 prefilled (only when
rooms.update). - Delete → S4 dialog (only when
rooms.delete). - Menu
(proposed): "Copy code" (clipboard — powers future QR/scanner flows). - Facilities: first 2 chips inline, tap "+n" → expand all (
AnimatedSize). - Availability section
(planned): shows upcoming bookings when bookings module exists. - QR card
(forward-looking): renderscodeas QR for door signage; hidden until signage feature ships.
2.6 Analytics (proposed)
rooms.detail.open, rooms.detail.edit, rooms.detail.menu.copy_code, rooms.detail.delete.start.
2.7 A11y & motion
- Header is one semantics group; facilities chips individually tappable with labels.
- Delete/mutation feedback via
m-fast; snackbar auto-dismiss with undo(proposed)(no undo API — restore = recreate).
S3 — Room Editor — Create (/rooms/new) / Edit (/rooms/:id/edit)
3.1 Purpose & entry
Create (from S1 FAB, gated rooms.create) or edit (from S2, gated rooms.update) a room.
Exit: save success → pop + snackbar; cancel → discard confirm if dirty; back arrow same.
3.2 Layout (scroll form)
┌───────────────────────────────────┐
│ AppBar "New room" / "Edit room" │
│ [Save] (AppBar action) │
├───────────────────────────────────┤
│ Name * [_____________] │
│ Code * [LAB-02 ] ✓ │ ← uniqueness hint (proposed)
│ Type [classroom ▾] │
│ Capacity [ 40 ] │ ← numeric, min 1 (proposed)
│ Building [ Building B ] │
│ Facilities [projector][AC][+] │ ← chip input
├───────────────────────────────────┤
│ (inline field errors / 409 box) │
└───────────────────────────────────┘
3.3 Data contract
| Item | Detail | Source |
|---|---|---|
| Create | POST /api/v1/rooms body CreateRoomDto | rooms.controller.ts:24-28; create-room.dto.ts:11-38 |
| Update | PATCH /api/v1/rooms/:id body CreateRoomDto — full DTO required (name+code mandatory on PATCH; no update-room.dto.ts — OQ-6) | rooms.controller.ts:42-46 |
| 409 | create: "Room code … already exists."; update: no check (OQ-1) | rooms.service.ts:19-21 |
| 400 | VALIDATION_ERROR details[] (e.g. type not in enum) | shared filter |
| 404 | update of unknown id: "Room not found." | rooms.service.ts:44 |
3.4 Field specs
| Field | Required | Type | Server validation (source) | Client rules (proposed) |
|---|---|---|---|---|
| name | yes | string | @IsString, trim (create-room.dto.ts:12-14; room.schema.ts:18-19) | non-empty; ≤ 120 chars; whitespace-trimmed |
| code | yes | string | @IsString, trim (create-room.dto.ts:16-18; room.schema.ts:21-22) | non-empty; unique hint; auto-uppercase suggestion; pattern [A-Z0-9-]{2,24} (proposed) |
| capacity | no | number | @IsNumber only — no min/zero guard (create-room.dto.ts:20-23; OQ-7) | integer ≥ 1; < 1 blocked; > 10 000 warning (proposed) |
| type | no (default classroom) | enum | @IsEnum(RoomType) (create-room.dto.ts:25-28; room.schema.ts:7-14,27-28) | dropdown from enum; default classroom |
| building | no | string | @IsString (create-room.dto.ts:30-33) | free text + suggestions from existing (proposed) |
| facilities | no | string[] | @IsArray of strings (element type unchecked) (create-room.dto.ts:35-38) | chip input, dedupe, max 12 chips (proposed) |
3.5 States & interactions
| State | UI |
|---|---|
| idle | form editable; Save enabled when required valid |
| validating | async code-uniqueness check (proposed) — GET list client-side match; spinner under field |
| submitting | Save spinner; fields disabled |
| success (201/200) | pop; snackbar "Room created"/"Room updated"; emit RoomChanged to list/detail (cache invalidation) |
| 409 | inline error under Code field; form kept; scroll-to-field (create). Update: blocked client-side until server check (planned) |
| 400 | field errors mapped from details[]; focus first invalid |
| 404 (edit) | pop + snackbar "Room not found" |
| offline | write blocked + banner |
- Dirty tracking: compare against initial model; back-arrow with dirty → discard dialog.
- Facilities chip input: type + Enter → chip; tap × removes; duplicates ignored.
- Capacity field: numeric keyboard, comma/point filtered
(proposed).
3.6 Analytics (proposed)
rooms.editor.create.open, rooms.editor.create.submit, rooms.editor.create.success,
rooms.editor.create.duplicate, rooms.editor.edit.open, rooms.editor.edit.submit,
rooms.editor.edit.success, rooms.editor.discard.
3.7 A11y & motion
- Labels linked to fields; errors in live regions;
m-fastfocus transitions. - Keyboard avoidance; next-field on Enter; Save via keyboard action
(proposed).
S4 — Delete Room — confirm dialog
4.1 Spec
| Item | Detail | Source |
|---|---|---|
| Trigger | Detail menu / list row menu (gated rooms.delete) | permissions.constants.ts:53 |
| Content | title "Delete room"; copy "Type {name} to confirm. This can't be undone." (soft-delete: actually recoverable via DB, not via UI); AppTextField confirm; buttons Cancel / Delete (disabled until text matches room.name) | — |
| Endpoint | DELETE /api/v1/rooms/:id | rooms.controller.ts:48-52 |
| Response | 200, no body payload (void — rooms.service.ts:48); client removes row locally | rooms.service.ts:48-51 |
| Errors | 404 → snackbar "Room not found" (already deleted); network → snackbar Retry | rooms.service.ts:50 |
| Gap | no in-use guard (OQ-4): informational copy "This room may appear in timetables" (proposed); server block (planned) | rooms.service.ts:48-51 |
| Analytics | rooms.delete.confirm, rooms.delete.cancel (proposed) | — |
5. Cross-screen rules
| Rule | Value |
|---|---|
| Version/optimistic lock | PATCH increments version (base.repository.ts:57-66); client does not send version today; concurrent-edit detection (proposed) via If-Match (planned) |
| 401/refresh | single-flight refresh + replay, then session expiry (00-shared/06 §3.6) |
| Offline | reads cached; writes blocked (no offline queue for this module) |
| Empty list after filters | AppEmptyState "No rooms match your filters" + clear-filters action |
| Deep links | studylyon://rooms , studylyon://rooms/:id (forward-looking) |
| Loading budgets | list ≤ 300 ms p95 target; detail ≤ 250 ms (00-shared/10 §1) |
07 — Component Library (Rooms Module)
- 1. RoomCard
- 2. RoomTypeIcon
- 3. CapacityBadge
- 4. FacilityChips
- 5. RoomFilterBar
- 6. RoomTypePicker
- 7. RoomCodeField
- 8. FacilityInputChips
- 9. DeleteRoomDialog
- 10. QRCodeCard
(forward-looking) - 11. RoomAvailabilitySection
(planned) - 12. Component usage matrix
Module-specific components used across the Rooms screens. Shared building blocks (AppTextField, AppButton, AppChips, AppCard, AppListTile, AppDialog, AppMenu, AppSkeleton, AppEmptyState, AppOfflineBanner, AppFAB, AppSnackbar, AppDropdown) come from 00-shared/03 and are referenced by name only. All
(proposed)Flutter design.
1. RoomCard
| Purpose | List-row unit for a room on S1 (also detail-pane row on tablet) |
| Data | Room (room.schema.ts:16-35): name, code, type, capacity?, building?, facilities[] |
| Layout | AppCard: leading RoomTypeIcon (type), title name, subtitle building · capacity ("Cap N" if present), trailing code chip; facilities preview: first 2 AppChips + "+n" |
| States | default, pressed (hover on desktop), loading (skeleton variant) |
| Semantics | single label "Lab 2, code LAB-02, capacity 40, Building B" |
| Motion | m-fast press feedback; cards fade-in staggered on load (00-shared/08) |
| Source refs | room.schema.ts:18-34 |
2. RoomTypeIcon
| Purpose | Map RoomType enum → icon + label + color ((proposed) palette, 00-shared/02) |
| Mapping | classroom → chair/school icon; lab → science/flask; library → book; office → briefcase; hall → event seat; other → generic room |
| Source refs | room.schema.ts:7-14 |
| Fallback | unknown value → other icon (defensive: enum may grow) |
3. CapacityBadge
| Purpose | Compact capacity display "Cap 40"; supports (proposed) color states once capacity bounds land (OQ-7): normal ≥ 1; warning/error for 0/negative that slipped through server (defensive) |
| Data | room.capacity (optional) — hidden if absent |
| Source refs | room.schema.ts:24-25; create-room.dto.ts:20-23 |
4. FacilityChips
| Purpose | Render facilities: string[] as chips; max 2 inline + "+n" expand (S2), full list (S2 expanded) |
| Interaction | tap chip → (forward-looking) filter-by-facility (needs server param (planned) OQ-3) |
| Source refs | room.schema.ts:33-34 |
5. RoomFilterBar
| Purpose | S1 filter controls: type chips (multi-select from enum) + building chips (derived) + search field |
| Behaviour | client-side filtering today (proposed) (OQ-3 — API has no filter params); becomes server-query UI when (planned) params land |
| Empty state | "No rooms match your filters" + "Clear filters" |
| Source refs | rooms.controller.ts:30-34; room.schema.ts:7-14,30-31 |
6. RoomTypePicker
| Purpose | S3 form control: AppDropdown of RoomType values, default classroom |
| Options | enum order as in room.schema.ts:7-14; label = capitalized value |
| Source refs | room.schema.ts:7-14; create-room.dto.ts:25-28 |
7. RoomCodeField
| Purpose | S3 code input with uniqueness affordance: live trim + uppercase suggestion (proposed); async duplicate check (proposed) (match against loaded list); on create shows ✓/✗ inline; on edit warns if changed onto existing (client can't fully verify — server check (planned) OQ-1) |
| Source refs | rooms.service.ts:19-21 (409 path); room.schema.ts:38 (unique index) |
8. FacilityInputChips
| Purpose | S3 chip-input for facilities: type + Enter → chip; × to remove; dedupe; max 12 (proposed) |
| Source refs | create-room.dto.ts:35-38 |
9. DeleteRoomDialog
| Purpose | S4 typed-confirm dialog: title, room name echoed, confirm text field must equal room.name to enable Delete |
| States | idle, mismatch (button disabled), submitting (spinner), error (snackbar retry), 404 (snackbar "already deleted" + pop) |
| Source refs | rooms.controller.ts:48-52; rooms.service.ts:48-51 |
10. QRCodeCard (forward-looking)
| Purpose | S2 card rendering room.code as QR for door signage; scan → room info/check-in |
| Status | (forward-looking) — requires signage/check-in feature decision; data already present (code) |
| Source refs | room.schema.ts:21-22 |
11. RoomAvailabilitySection (planned)
| Purpose | S2 "Availability" block driven by bookings module (planned); skeleton + empty state "No upcoming bookings" |
| Status | (planned) — hidden until bookings module exists (01 §9) |
12. Component usage matrix
| Screen | Components |
|---|---|
| S1 List | RoomCard, RoomTypeIcon, CapacityBadge, FacilityChips, RoomFilterBar, shared: AppAppBar, AppSearchBar, AppFAB, AppSkeleton, AppEmptyState, AppErrorState, AppOfflineBanner |
| S2 Detail | RoomTypeIcon, CapacityBadge, FacilityChips, QRCodeCard (forward-looking), RoomAvailabilitySection (planned), shared: AppCard, AppMenu, AppButton, AppSnackbar |
| S3 Editor | RoomTypePicker, RoomCodeField, FacilityInputChips, shared: AppTextField, AppDropdown, AppButton, AppSnackbar, AppDialog (discard) |
| S4 Delete | DeleteRoomDialog |
08 — Form Specifications (Rooms Module)
- 1. Form overview
- 2. Field matrix
- 3. Validation behaviour
- 4. Submit semantics
- 5. Dirty & discard
- 6. Keyboard & a11y
- 7. Error-code → copy map
(proposed)
The single form of the module (S3 — Room Editor). Field rules derive from
create-room.dto.ts(server truth) plus the schema (room.schema.ts); client-side rules are(proposed)where the server imposes none. Markings:(planned)= needs backend change,(forward-looking)= future.
1. Form overview
| Create | Edit | |
|---|---|---|
| Route | /rooms/new | /rooms/:id/edit |
| Endpoint | POST /api/v1/rooms (rooms.controller.ts:24-28) | PATCH /api/v1/rooms/:id (rooms.controller.ts:42-46) |
| Payload DTO | CreateRoomDto (create-room.dto.ts:11-38) | same CreateRoomDto — there is no update-room.dto.ts; name+code are required even on PATCH (OQ-6, rooms.controller.ts:44) |
| Guard | JwtAuthGuard; client gates by rooms.create / rooms.update (server RBAC (planned) — OQ-2) | same |
| Prefill | none (empty form) | full Room doc from S2 (GET /rooms/:id — rooms.controller.ts:36-40) |
2. Field matrix
| # | Field | Required | Server rule (source) | Client rule (proposed) | Notes |
|---|---|---|---|---|---|
| F1 | name | ✅ | @IsString (create-room.dto.ts:12-14); trim: true (room.schema.ts:18-19) | non-empty; ≤ 120 chars; trimmed | — |
| F2 | code | ✅ | @IsString (create-room.dto.ts:16-18); trim (room.schema.ts:21-22); unique per tenant (create 409 rooms.service.ts:19-21; unique index room.schema.ts:38) | non-empty; pattern [A-Z0-9-]{2,24}; uppercase suggestion; async duplicate hint (proposed) | edit-path duplicate check (planned) OQ-1 |
| F3 | capacity | ❌ | @IsNumber only (create-room.dto.ts:20-23) — no bounds (OQ-7) | integer ≥ 1; reject < 1 and non-integer input; warn > 10 000 | server @Min(1) (planned) |
| F4 | type | ❌ (default classroom) | @IsEnum(RoomType) (create-room.dto.ts:25-28); default classroom (room.schema.ts:27-28) | dropdown of RoomType values (room.schema.ts:7-14) | — |
| F5 | building | ❌ | @IsString (create-room.dto.ts:30-33) | free text; suggestions from loaded list (proposed); ≤ 60 chars | — |
| F6 | facilities | ❌ | @IsArray (create-room.dto.ts:35-38) — element type unchecked; stored [String] (room.schema.ts:33-34) | chip input; dedupe; max 12; each ≤ 32 chars (proposed) | free-text tags; (forward-looking) structured equipment/status fields (OQ-5) |
3. Validation behaviour
| Layer | Rule | UX |
|---|---|---|
| Required (F1, F2) | validate on submit + on blur after first submit attempt | inline error under field; focus first invalid |
| F3 range | < 1 blocked on input ((proposed)); 0/negative impossible client-side | inline hint "Capacity must be at least 1" |
| F2 uniqueness (create) | async check (proposed): debounce 300 ms, match against GET /rooms client list; server 409 is authoritative | inline ✓ / ✗ under Code; 409 → scroll-to-field + error box |
| F2 uniqueness (edit) | not checkable client-side reliably (list may be paginated); server check (planned) OQ-1 | warning copy "Code must be unique"; server 500 today → mapped to generic error + support note (proposed) |
| Enum (F4) | server 400 if out-of-range — impossible via dropdown | — |
| Submit | full DTO serialized; unknown keys never sent (whitelist = DTO fields) | Save button loading; fields disabled |
4. Submit semantics
- Create: on 201 → pop to S2 (detail) + snackbar "Room created" + cache invalidation.
- Edit: on 200 → pop to S2 + snackbar "Room updated"; S2 refetches (or reconciles from
response doc —
updateByIdreturns the updated doc,base.repository.ts:57-66). - 409 (create): form kept, inline duplicate error, no reset.
- 400: map
details[]→ field errors (envelope convention, 00-shared/07). - 404 (edit): pop + snackbar "Room not found".
- Offline: block submit, banner, draft kept in memory (no autosave).
- Concurrent edit
(proposed):versionincrements server-side (base.repository.ts:63); client sendsbaseVersion(planned)forIf-Match-style conflict → "Room was updated elsewhere" dialog with reload/discard.
5. Dirty & discard
- Dirty = any field differs from the initial model (or any chip added/removed).
- Back/close with dirty →
AppDialog"Discard changes?" [Keep editing] [Discard]. - No autosave; draft lost on discard.
6. Keyboard & a11y
- Order: F1 → F2 → F4 (dropdown) → F3 (numeric) → F5 → F6 (chip input).
- F3 keyboard:
TextInputType.number; F1/F2/F5: text with autocapitalize words (F2characters+ uppercase transform(proposed)). - Labels linked; errors in live regions; scroll-to-first-invalid; keyboard avoidance (00-shared/09).
7. Error-code → copy map (proposed)
| Code | Copy |
|---|---|
DUPLICATE_RESOURCE (409) | "A room with this code already exists." (code field) |
VALIDATION_ERROR (400) | per-field messages from details[] |
RESOURCE_NOT_FOUND (404) | "This room no longer exists." |
RATE_LIMITED (429) | "Too many requests — try again shortly." |
| 5xx | "Something went wrong (requestId …)." |
09 — User Behaviour (Rooms Module)
Observed/expected user behaviours around the room catalog, and the
(proposed)analytics events that measure them. Where a behaviour hits a backend gap, the gap is named (OQ-x, see 01 §10).
1. Behaviours & patterns
1.1 Browsing & filtering (S1)
- Users scan for capacity + type first ("which lab fits 40?"), building second.
- Without server filter/sort (OQ-3), users page through
_id-ordered data — behaviour: client-side chips + search(proposed); long-term server params(planned). - Pull-to-refresh is the trust gesture after admin edits elsewhere.
1.2 Code hygiene (S3)
- Users type lowercase/mixed codes ("Lab 2", "lab-2"); client uppercases + trims
(proposed). - Duplicate attempts are common at term start (copy-paste of previous room). Create-time
409 (authoritative,
rooms.service.ts:19-21) + inline hint resolve it. - Edit-path duplicate (OQ-1) is a real trap: rename onto an existing code → 500 today;
users retry, get same 500 → ticket. Mitigation
(proposed): client warning + server pre-check(planned).
1.3 Capacity behaviour (OQ-7)
- Server accepts negative/zero capacity (
@IsNumberonly —create-room.dto.ts:20-23); users have entered0for "unknown". Client blocks< 1(proposed)and shows "Capacity unknown" state for absent capacity — never a fake0.
1.4 Deletion (S4)
- Users delete rather than archive; they expect the room to disappear from lists
(it does — soft delete,
rooms.service.ts:48-51) and to be able to "undo" (they can't — restore is DB-level; snackbar copy says "deleted"). - In-use rooms get deleted (OQ-4) — timetable planner later sees a missing venue.
Copy warning
(proposed); server in-use guard(planned). - Typed-name confirm reduces accidental deletes; analytics track cancel-vs-confirm.
1.5 Permission-denied
- Without server RBAC (OQ-2), a read-only user with a crafted request can mutate rooms;
client gates UI, but the server guard is the real fix
(planned). Client treats future 403 as route-hide + 403 screen.
1.6 Offline
- Reads: last-good cache + banner; stale list is acceptable (catalog changes rarely).
- Writes: blocked — no offline queue for this module (reference data; low urgency).
2. Analytics events (proposed)
| Event | Trigger | Screen |
|---|---|---|
rooms.list.open | screen shown | S1 |
rooms.list.search (term) | search debounce fires | S1 |
rooms.list.filter.type (type) | chip toggle | S1 |
rooms.list.filter.building | chip toggle | S1 |
rooms.list.filter.clear | "Clear filters" | S1 |
rooms.list.load_more (page) | infinite scroll fetch | S1 |
rooms.list.refresh | pull-to-refresh | S1 |
rooms.list.row_tap (id) | row → detail | S1 |
rooms.detail.open (id) | screen shown | S2 |
rooms.detail.edit | Edit pressed | S2 |
rooms.detail.delete.start | Delete menu pressed | S2 |
rooms.detail.copy_code | menu copy | S2 |
rooms.editor.create.open / .edit.open | form shown | S3 |
rooms.editor.create.submit / .edit.submit | Save pressed | S3 |
rooms.editor.create.success / .edit.success | 201/200 | S3 |
rooms.editor.create.duplicate | 409 | S3 |
rooms.editor.discard | discard confirm | S3 |
rooms.delete.confirm / rooms.delete.cancel | dialog action | S4 |
rooms.checkin.scan | QR scan at venue | (forward-looking) |
rooms.booking.{book,cancel} | booking actions | (planned) |
rooms.analytics.utilization | utilization report view | (proposed) |
3. Behavioural rules
| Rule | Rationale |
|---|---|
| No optimistic writes on S3/S4 — always wait for server result | conflict/duplicate semantics must stay authoritative; remove() returns void so local removal happens only after 200 |
| Local row removal on delete success (no refetch) | DELETE has no payload (rooms.service.ts:48) |
| Filter state survives list-scroll within session, resets on route pop | predictable resets (00-shared/05) |
| Search debounce 300 ms; code-dup check debounce 300 ms | input budget (00-shared/10 §1) |
| Empty filter result keeps filter visible with "clear" CTA | recoverability over dead-ends |
| 429/rate-limit → backoff countdown on submit, no auto-retry | shared error handling (00-shared/06 §5) |
10 — Interaction Specification (Rooms Module)
- 1. S1 — Room List
- 2. S2 — Room Detail
- 3. S3 — Room Editor
- 4. S4 — Delete Dialog
- 5. Loading & feedback matrix
- 6. Offline & connectivity
- 7. Motion budget
Concrete interactions per screen: gestures, focus, feedback, dialogs, motion, loading, and offline behaviour. Motion tokens per 00-shared/08, interaction baseline per 00-shared/10. All client design is
(proposed)unless marked.
1. S1 — Room List
| Interaction | Spec |
|---|---|
| Row tap | push S2 (phone); select in master-detail pane (≥ 840 dp) without pushing; hover highlight on desktop |
| Pull-to-refresh | RefreshIndicator; bypasses cache; resets to page 1; error → snackbar with Retry |
| Infinite scroll | trigger at 80% viewport; fetch page+1 when hasNext; bottom spinner; dedupe by _id; failure → inline retry row (keep loaded items) |
| Search | debounce 300 ms; client filter (proposed); clears on X; no results → empty state with "Clear filters" |
| Filter chips | multi-select type chips; building chips derived; chip tap toggles (not navigate); selected chips show removal × |
| FAB | "New room" — visible only with rooms.create; tap → push /rooms/new |
Row menu (proposed) | delete shortcut (gated rooms.delete) → S4 dialog |
| Loading | 6 skeleton RoomCards, staggered m-fast fade-in |
| Feedback | mutations from S2/S4 reflected: row removed + count badge decrement; snackbar shown by originating screen |
Motion: card entrance fade/slide 8 dp m-fast; refresh spinner standard; empty-state
m-base fade (00-shared/08).
2. S2 — Room Detail
| Interaction | Spec |
|---|---|
| Back | pop to S1 (list state preserved incl. filters) |
| Edit | gated rooms.update; push /rooms/:id/edit prefilled |
| Delete | gated rooms.delete; opens S4 dialog (never inline) |
| Menu | ⋮ → "Copy code" (clipboard + snackbar "Code copied"); future "QR code" (forward-looking) |
| Facilities | first 2 chips + "+n" button; tap → expand full list (AnimatedSize, m-fast); collapse back |
| Stale detail | if list refresh shows the room was deleted (404) → pop + snackbar "Room no longer exists" |
| Offline | cached doc + banner; actions (edit/delete) disabled until online |
Availability (planned) | section with skeleton; empty state "No upcoming bookings"; row tap → booking detail (planned) |
3. S3 — Room Editor
| Interaction | Spec |
|---|---|
| Save (AppBar action) | validates → submit; spinner in place of icon; disabled while submitting |
| Keyboard | Enter advances field; F3 numeric; save action (proposed) from keyboard on F6 |
| Code field | uppercase transform + trim on input; async uniqueness check (300 ms debounce) → ✓/✗; on create only (edit: warning (proposed), server check (planned)) |
| Type dropdown | modal/AppDropdown; selection closes and applies immediately; default classroom |
| Facilities chip input | type + Enter/, → chip; backspace on empty → remove last chip; × removes; duplicates ignored; max 12 then input disabled with hint |
| Discard | dirty + back → dialog [Keep editing][Discard]; discard pops without saving |
| Submit results | 201/200 → pop + snackbar; 409 → inline error + scroll-to-field; 400 → field errors + focus first invalid; offline → block + banner (draft kept) |
Concurrent edit (proposed) | server version bump (base.repository.ts:57-66); client conflict dialog on stale-base submit (planned) |
Motion: field error → m-fast shake on the offending field; snackbar m-base slide.
4. S4 — Delete Dialog
| Interaction | Spec |
|---|---|
| Open | from S2 menu or S1 row menu (gated rooms.delete) |
| Confirm | text field must equal room.name (case-sensitive) to enable Delete; mismatch → button disabled; match → enabled |
| Submit | button spinner; fields disabled; no dismiss on tap-out while submitting |
| Success | dialog closes; if on S2 → pop to S1; row removed; snackbar "Room deleted" |
| 404 | dialog closes; snackbar "Room not found — it may already be deleted" |
| Network fail | dialog stays; snackbar "Couldn't delete — Retry" |
| Cancel / tap-out | closes; analytics rooms.delete.cancel |
In-use warning (proposed) | copy "This room may be referenced by timetables or bookings" — server in-use guard (planned) (OQ-4) |
5. Loading & feedback matrix
| Action | Optimistic? | Loading UI | Success | Failure |
|---|---|---|---|---|
| List load / load-more | no | skeletons / bottom row | cards | retry row / snackbar |
| Detail load | no | skeleton | profile | error state + Retry |
| Create / update | no (server authoritative) | button spinner | pop + snackbar | inline errors / snackbar |
| Delete | no | dialog spinner | pop + snackbar | snackbar |
| Copy code | yes (clipboard) | — | snackbar "Copied" | — |
6. Offline & connectivity
- Reads: last-good cache +
AppOfflineBanner(no module offline queue — reference data). - Writes: blocked with inline banner "You're offline"; draft preserved in cubit memory.
- Reconnect: banner clears; user re-triggers (pull-to-refresh / re-submit).
7. Motion budget
| Motion | Token | Use |
|---|---|---|
| Entrance | m-fast | cards, detail fade, chips |
| Feedback | m-fast | press states, error shake |
| Transitions | m-base | route push/pop, snackbar |
| Expansion | m-base | facilities expand/collapse (AnimatedSize) |
11 — Design System Mapping (Rooms Module)
- 1. Token usage
- 2. Component mapping
- 3. Visual rules
- 4. Layout system (00-shared/05)
- 5. Type → icon/color mapping
(proposed) - 6. Accessibility mapping (00-shared/09)
Maps the Rooms module onto the shared design system. Tokens, components, layouts, and a11y baselines live in 00-shared/02 (Design Tokens), 00-shared/03 (Component Library), 00-shared/04 (Design System Mapping), and 00-shared/05 (Global IA). This file is the module's mapping layer only — it does not re-specify shared rules.
1. Token usage
| Token group (00-shared/02) | Module use |
|---|---|
| Color | type-tinted icons via colorScheme.primaryContainer variants (proposed); error color reserved for capacity/duplicate errors; no literal colors in widgets |
| Typography | titleMedium card titles, labelMedium code chips, bodyMedium meta, bodySmall captions |
| Shape | shapeMedium cards, shapeFull chips/badges, shapeSmall icon containers |
| Spacing | 16 dp page padding, 8 dp between rows/chips, 24 dp section gaps |
| Motion | m-fast entrance/feedback, m-base transitions (see 10 §7) |
| Elevation | resting cards level-1, pressed level-2, dialog level-3 |
2. Component mapping
| Shared component (00-shared/03) | Module usage |
|---|---|
AppAppBar | S1/S2/S3 title + actions (Save in S3) |
AppSearchBar | S1 search (client filter (proposed)) |
AppChips | type/building filters, facilities, code chip |
AppCard | RoomCard, detail header |
AppListTile | meta rows, availability rows (planned) |
AppTextField | S3 fields, S4 confirm |
AppDropdown | RoomTypePicker |
AppButton | Edit/Delete, dialog actions |
AppFAB | S1 "New room" (gated rooms.create) |
AppSkeleton | list/detail/availability loading |
AppEmptyState | no rooms, no filters match, not found |
AppErrorState | list/detail retry |
AppOfflineBanner | connectivity |
AppSnackbar | all mutation feedback |
AppDialog | S4 delete, discard, concurrent-edit (proposed) |
AppMenu | row/detail actions |
Module-specific components (07): RoomCard, RoomTypeIcon, CapacityBadge,
FacilityChips, RoomFilterBar, RoomTypePicker, RoomCodeField, FacilityInputChips,
DeleteRoomDialog, QRCodeCard (forward-looking), RoomAvailabilitySection (planned).
3. Visual rules
| Rule | Spec |
|---|---|
| Type identity | RoomTypeIcon per enum (room.schema.ts:7-14); consistent tint per type across list/detail/editor |
| Code prominence | code chip = monospace labelMedium on surfaceContainerHighest; distinct from name |
| Capacity display | "Cap N" CapacityBadge; absent capacity → no badge (never "Cap 0") |
| Building | subtitle text with location icon when present |
| Facilities | chips ≤ 2 inline; "+n" expander |
| Permission gating | hidden UI, never disabled-looking buttons for missing perms (per 00-shared/04) |
| Empty vs not-found | distinct art + copy: "No rooms yet" (list) vs "Room not found" (404 detail) |
4. Layout system (00-shared/05)
| Breakpoint | S1 | S2 | S3 |
|---|---|---|---|
| < 600 dp (phone) | single column + FAB | single column | single column, keyboard-avoiding |
| 600–839 dp (tablet portrait) | 2-column grid of cards (proposed) | column + sticky actions | ≤ 640 dp centered card |
| ≥ 840 dp (tablet/desktop) | master-detail split (list + detail pane) | pane content | centered card |
5. Type → icon/color mapping (proposed)
RoomType | Icon | Tint |
|---|---|---|
classroom | school/chair | primary |
lab | science/flask | secondary |
library | book | tertiary |
office | briefcase | secondary-variant |
hall | event-seat | tertiary-variant |
other | room/generic | neutral |
6. Accessibility mapping (00-shared/09)
| Rule | Module application |
|---|---|
| Contrast | all status/capacity info uses standard tokens (≥ 4.5:1 body) |
| Touch targets | chips ≥ 44 dp hit area; FAB standard; row menus 48 dp |
| Semantics | RoomCard single label; facilities chips labeled individually on S2 |
| Live regions | loading announcements, field errors, snackbar content |
| Focus order | S1: search → chips → cards → FAB; S3: F1→F6 |
| Reduced motion | respect disableAnimations; error shake → color-only (proposed) |
12 — API Mapping (Rooms Module)
- E1 — Create room
- E2 — List rooms
- E3 — Get room by ID
- E4 — Update room
- E5 — Delete room (soft)
- Client contract summary (all screens)
- Endpoint → screen matrix
Exact endpoints per screen. Wire contract per 00-shared/07: base
/api/v1, Bearer JWT, success{success:true,message:"OK",data,meta?,timestamp,requestId}, error envelope with codes. Only shapes in code are used.(planned)/(forward-looking)marked.
E1 — Create room
| Endpoint | POST /api/v1/rooms (rooms.controller.ts:24-28) |
| Guard | JwtAuthGuard only (rooms.controller.ts:19); RBAC rooms.create not enforced server-side (OQ-2, permissions.constants.ts:51); client gates UI |
| Request | CreateRoomDto (create-room.dto.ts:11-38) — name, code required; capacity, type (enum, default classroom), building, facilities optional |
| Response | 201 envelope, data = saved room doc (rooms.service.ts:18-23); tenantId injected from context, never from body (base.repository.ts:32-36) |
| Errors | 400 VALIDATION_ERROR (missing name/code, bad type); 409 ConflictException "Room code "X" already exists." (rooms.service.ts:19-21); 401; 429 RATE_LIMITED; 5xx |
| Client | S3 create; on success → detail + snackbar; 409 → inline under code field |
| Cache | none (write); on success invalidate list cache key |
E2 — List rooms
| Endpoint | GET /api/v1/rooms?page&limit (rooms.controller.ts:30-34) |
| Params | page ≥ 1 default 1; limit default 20 (rooms.controller.ts:32); no sort, q, or filters in code — PaginationQueryDto.sort/q (pagination-query.dto.ts:21-29) unused on this route (OQ-3); order = Mongo natural (_id) order |
| Response | 200 envelope: data: Room[] + meta {page, limit, totalItems, totalPages, hasNext, hasPrevious} (rooms.service.ts:25-34; pagination-query.dto.ts:32-39,41-54) |
| Errors | 401; 429; 5xx |
| Client | S1; infinite scroll on hasNext; pull-to-refresh; client-side filter/search (proposed) until server params (planned) |
| Cache | client paginated cache sl:{tenantId}:rooms:{page}:{limit} TTL 5 min; invalidated on E1/E4/E5 success |
E3 — Get room by ID
| Endpoint | GET /api/v1/rooms/:id (rooms.controller.ts:36-40) |
| Response | 200 envelope, data = room doc (rooms.service.ts:36-40) |
| Errors | 400 VALIDATION_ERROR (invalid ObjectId → CastError mapping, shared filter); 404 RESOURCE_NOT_FOUND "Room not found." (rooms.service.ts:38); cross-tenant id → 404, no existence leak (base.repository.ts:24-29); 401; 429 |
| Client | S2 detail + S3 edit prefill; stale-while-revalidate OK |
E4 — Update room
| Endpoint | PATCH /api/v1/rooms/:id (rooms.controller.ts:42-46) |
| Request | CreateRoomDto (rooms.controller.ts:44) — there is no update-room.dto.ts (OQ-6); name+code remain required on PATCH; partial body → 400 |
| Behaviour | updateById → $set: dto + $inc: {version: 1}, returns updated doc (rooms.service.ts:42-46; base.repository.ts:57-66) |
| Errors | 400 (missing required fields, bad enum); 404 (rooms.service.ts:44); duplicate-code update → uncaught E11000 unique-index error → 500 (OQ-1) (room.schema.ts:38); 401; 429 |
| Client | S3 edit; success → detail reconcile from response; client-side duplicate warning (proposed); server pre-check (planned) OQ-1 |
| Cache | invalidate list + detail keys on success |
E5 — Delete room (soft)
| Endpoint | DELETE /api/v1/rooms/:id (rooms.controller.ts:48-52) |
| Behaviour | soft delete: isDeleted:true, deletedAt, deletedBy + version (rooms.service.ts:48-51; base.repository.ts:68-74); returns 200, no payload (void handler) |
| Errors | 404 (rooms.service.ts:50); 401; 429 |
| Gaps | no in-use guard (OQ-4) — succeeds even if referenced by timetable/bookings; hard purge not scheduled (rooms never hard-deleted in code) |
| Client | S4 typed-confirm dialog; on success remove row locally + snackbar; 404 → "already deleted" |
| Audit | deletedAt/deletedBy recorded (base.schema.ts:23-27) — future audit surface (planned) (IMPLEMENTATION_PLAN.md Phase 5) |
Client contract summary (all screens)
| Concern | Rule |
|---|---|
| Auth | Bearer JWT; 401 → single-flight refresh → replay; fail → session expiry (00-shared/06 §3.6) |
| RBAC | server does not enforce rooms.* today (OQ-2); client gates by permission list from permissions.constants.ts:50-53; when server guard lands, treat 403 as route-hide |
| Optimistic | none for mutations — server result always shown (delete is void; local removal after 200) |
| Idempotency | PATCH/DELETE retry-safe; no Idempotency-Key support confirmed |
| Offline | reads from last-good cache + banner; writes blocked (no module offline queue) |
| Pagination | page/limit + meta exact (pagination-query.dto.ts:5-54); infinite scroll driven by hasNext |
| Filtering | client-side (proposed); server params (planned) OQ-3 |
| Realtime | no WS topics today; (forward-looking) rooms.updated for multi-device sync |
| Error mapping | 00-shared/06 §5: 400 field errors, 403 hide/deny, 404 empty/not-found, 409 inline duplicate, 429 backoff, 5xx generic+requestId |
Endpoint → screen matrix
| Endpoint | S1 List | S2 Detail | S3 Editor | S4 Delete |
|---|---|---|---|---|
POST /rooms | — | — | create submit | — |
GET /rooms?page&limit | load/load-more/refresh | — | duplicate hint (proposed) | — |
GET /rooms/:id | — | load | edit prefill | — |
PATCH /rooms/:id | — | reconcile | edit submit | — |
DELETE /rooms/:id | row remove | menu trigger | — | confirm submit |
13 — State Management (Rooms Module)
- 1. Cubit map
- 2. State machine (generic per 00-shared/06 §3.1)
- 3. RoomListCubit
- 4. RoomDetailCubit
- 5. RoomEditorCubit
- 6. Caching & staleness (module TTLs)
- 7. Realtime & cross-cubit
- 8. Offline & connectivity
- 9. Testing hooks
Cubit architecture per 00-shared/06. One cubit per screen; base
LoadState(Initial/Loading/Success/Error(ApiException)),PaginatedListMixin, cache + SWR, optimistic updates, connectivity. All(proposed)client design.
1. Cubit map
| Cubit | Screen(s) | Data sources | Notes |
|---|---|---|---|
RoomListCubit | S1 | E2 GET /rooms?page&limit (+ client filter (proposed)) | PaginatedListMixin<Room>; local filter/search state; cache invalidation on mutations |
RoomDetailCubit | S2 | E3 GET /rooms/:id | TTL 5 min SWR; reconciles after edit; pops on 404 |
RoomEditorCubit | S3 create/edit | E1 POST / E4 PATCH | form model + validation; 409 inline; dirty tracking |
(future) RoomAvailabilityCubit | S2 availability | bookings module (planned) | (planned) — hidden until bookings lands |
(future) RoomCheckinCubit | QR scan | QR endpoint (forward-looking) | (forward-looking) |
2. State machine (generic per 00-shared/06 §3.1)
stateDiagram-v2
[*] --> Initial
Initial --> Loading: Load
Loading --> Success: load ok
Loading --> Error: ApiException
Error --> Loading: Retry
Success --> Success: Refresh / mutate (reconcile)
Success --> Error: mutation fails (delete/update)
Success --> Loading: pull-to-refresh (bypass cache)
3. RoomListCubit
State: page (1), limit (20), items: List<Room>, hasNext, isLoadingMore, loadState,
filters {types: Set<RoomType>, buildings: Set<String>, query} (proposed) — client-side
until server params land (OQ-3).
Events: LoadFirst(), LoadMore(), Refresh(), SetTypeFilter(t), SetBuildingFilter(b),
SetQuery(q), ClearFilters(), RoomChanged(room) (post-mutation reconcile), RoomDeleted(id).
Load flow: cache hit (sl:{tenant}:rooms:{page}:{limit}, TTL 5 min) → Success(stale:true)
- background refetch; miss → Loading → Success/Error. On
LoadMorewhilehasNext→ append + dedupe by_id.
sequenceDiagram
participant C as RoomListCubit
participant R as RoomsRepository
participant API as GET /rooms?page&limit
C->>C: LoadFirst()
C->>R: list(page:1, limit:20)
API-->>R: data[] + meta {totalItems,hasNext,...}
R-->>C: Success(items, meta)
C->>C: scroll ≥80% && hasNext
C->>R: list(page:2, limit:20)
API-->>R: data[] + meta
R-->>C: append items
4. RoomDetailCubit
State: room, stale (cache-served), loadState.
Events: LoadRoom(id), Refresh(), RoomUpdated(room) (reconcile from editor), Deleted().
Key reducer: Deleted() → emit terminal state; UI pops to list (route-level), list
cubit removes row via RoomDeleted event. 404 → NotFound state (renders "Room not
found" + back), not generic error — because cross-tenant and soft-deleted ids both 404
(rooms.service.ts:38,44,50).
stateDiagram-v2
[*] --> Initial
Initial --> Loading: LoadRoom(id)
Loading --> Success: 200
Loading --> NotFound: 404
Loading --> Error: network/5xx
Success --> Success: RoomUpdated / Refresh
Success --> Deleted: Deleted() → pop to list
NotFound --> Loading: Retry (id re-entered)
5. RoomEditorCubit
State: form: RoomFormModel (F1–F6), initial: RoomFormModel (dirty baseline),
submitting, fieldErrors: Map<String,String>, duplicateHint: bool (proposed),
saveResult.
Events: InitCreate(), InitEdit(room), FieldChanged(field, value), CodeChanged(code)
(triggers debounced duplicate hint against list cache), Submit(), Discard().
Reducers: submit → submitting → on 201/200 emit RoomChanged(room) (via module bus) +
pop + snackbar; on 409 → duplicateHint=true, inline error, no reset; on 400 → fieldErrors
mapped from envelope details[]; on 404 (edit) → pop + "Room not found".
sequenceDiagram
participant W as RoomEditorCubit
participant R as RoomsRepository
participant API as POST /rooms (or PATCH /rooms/:id)
W->>W: Submit()
W->>R: create(dto) / update(id, dto)
alt 201/200
API-->>R: room doc
R-->>W: Success(room) → pop + snackbar
W-->>Bus: RoomChanged(room)
else 409
R-->>W: ApiException(DUPLICATE_RESOURCE)
W-->>UI: inline error under code
else 400
R-->>W: ApiException(VALIDATION_ERROR)
W-->>UI: fieldErrors from details[]
else 404
W-->>UI: pop + "Room not found"
end
6. Caching & staleness (module TTLs)
| Data | Cache key | TTL | Notes |
|---|---|---|---|
| Room list page | sl:{tenant}:rooms:{page}:{limit} | 5 min SWR | invalidated on E1/E4/E5 success |
| Room detail | sl:{tenant}:rooms:{id} | 5 min SWR | reconcile after edit; pop on 404 |
Duplicate-hint code set (proposed) | derived from list cache | — | not authoritative; server 409 is |
Buildings/type dictionary (proposed) | derived from list cache | — | refresh on list refresh |
7. Realtime & cross-cubit
- No WS topics today;
(forward-looking): subscriberooms.updated→ invalidate list + detail caches + refetch (multi-device admin edits). - Cross-cubit invalidation: after E1/E4 success → bump
rooms:{id}+ list keys;RoomDeleted→ list row removal without refetch (delete is void —rooms.service.ts:48). - Module bus events:
RoomChanged(room),RoomDeleted(id)consumed by list; editor listens to nothing (form state is local). - Future
(planned): bookings availability state lives in the bookings module's cubits, not here.
8. Offline & connectivity
- Reads: last-good cache +
AppOfflineBanner;LoadMoreblocked offline (no stale pages). - Writes: blocked in
RoomEditorCubit.Submit()(emit error "You're offline", draft kept). - Reconnect: banner clears; user re-triggers.
9. Testing hooks
- Pure-Dart cubits, mocked repositories; widget tests per state machine
(Loading/Success/Error/NotFound/Empty) + 409-inline + delete-flow (
00-shared/06 §6). - Golden: list with filters applied; editor with duplicate hint; typed-confirm disabled state.
14 — QA Checklist (Rooms Module)
- 1. CRUD — create (E1)
- 2. CRUD — list (E2)
- 3. CRUD — get (E3)
- 4. CRUD — update (E4)
- 5. CRUD — delete (E5)
- 6. Duplicates (create-path contract)
- 7. Capacity validation
- 8. Permission & isolation
- 9. UX / offline / edge
- 10. Accessibility (spot; full baseline 00-shared/09)
- 11. Performance (budgets 00-shared/10 §1)
- 12. Regression suite (minimum)
Feature-level QA for the Rooms module, derived from the implemented backend (
src/modules/rooms/**) and the design docs 01–13. Server gaps are marked(planned); tests that would fail today are marked KNOWN-GAP. Baseline: 00-shared/10 (QA Baseline), 00-shared/09 (Accessibility).
1. CRUD — create (E1)
| # | Check | Expected | Source |
|---|---|---|---|
| 1.1 | POST with valid full payload | 201, doc with injected tenantId, defaults applied (type=classroom, isDeleted=false, version=0) | rooms.service.ts:18-23; room.schema.ts:27-28; base.repository.ts:32-36 |
| 1.2 | POST missing name | 400 VALIDATION_ERROR | create-room.dto.ts:12-14 |
| 1.3 | POST missing code | 400 | create-room.dto.ts:16-18 |
| 1.4 | POST duplicate code in same tenant | 409 "Room code "X" already exists." | rooms.service.ts:19-21 |
| 1.5 | POST same code in different tenant | 201 (per-tenant uniqueness) | room.schema.ts:38 ({tenantId, code} index) |
| 1.6 | POST invalid type | 400 (enum) | create-room.dto.ts:25-28 |
| 1.7 | POST capacity: 0 / -5 / 2.5 | KNOWN-GAP: accepted (no bounds — OQ-7); client blocks <1 (proposed); server @Min(1) (planned) | create-room.dto.ts:20-23 |
| 1.8 | POST body includes tenantId/isDeleted | ignored — overridden by context/scope (never trust body) | base.repository.ts:32-36 |
| 1.9 | POST without JWT | 401 | rooms.controller.ts:19 |
2. CRUD — list (E2)
| # | Check | Expected | Source |
|---|---|---|---|
| 2.1 | GET with defaults | 200, data ≤ 20, meta {page:1,limit:20,totalItems,totalPages,hasNext,hasPrevious} | rooms.controller.ts:32; rooms.service.ts:29-33; pagination-query.dto.ts:32-39 |
| 2.2 | page=2, limit=10 | correct slice; hasNext reflects remainder | rooms.service.ts:29-32 |
| 2.3 | page=0 / negative | KNOWN-GAP: no DTO validation on this route — raw Number() cast (rooms.controller.ts:32-33); page=0 → skip=-10 (Mongoose clamps, but contract unguarded). Shared DTO caps 1–100, not applied here (OQ-3). Server validation (planned) | rooms.controller.ts:32-33; pagination-query.dto.ts:5-19 |
| 2.4 | sort / q params | KNOWN-GAP: ignored — list is Mongo natural order (OQ-3) | rooms.controller.ts:32 |
| 2.5 | Deleted rooms in list | excluded (isDeleted:false scope) | base.repository.ts:20-30 |
| 2.6 | Cross-tenant rows | never visible (tenant scope) | base.repository.ts:24-29 |
| 2.7 | Empty tenant | 200, data: [], totalItems: 0 | rooms.service.ts:29-33 |
3. CRUD — get (E3)
| # | Check | Expected | Source |
|---|---|---|---|
| 3.1 | Existing id | 200 full doc | rooms.service.ts:36-40 |
| 3.2 | Unknown / soft-deleted id | 404 "Room not found." | rooms.service.ts:38; base.repository.ts:20-30 |
| 3.3 | Other tenant's id | 404 (no leak) | base.repository.ts:24-29 |
| 3.4 | Malformed id (not ObjectId) | 400 VALIDATION_ERROR | shared CastError filter |
4. CRUD — update (E4)
| # | Check | Expected | Source |
|---|---|---|---|
| 4.1 | PATCH full valid body | 200 updated doc, version +1 | base.repository.ts:57-66 |
| 4.2 | PATCH partial body (only building) | KNOWN-GAP: 400 — CreateRoomDto requires name+code on PATCH (OQ-6, rooms.controller.ts:44); true partial DTO (planned) | rooms.controller.ts:44 |
| 4.3 | PATCH code → existing code | KNOWN-GAP: 500 (E11000) — no service pre-check (OQ-1); 409 (planned) | rooms.service.ts:42-46; room.schema.ts:38 |
| 4.4 | PATCH unknown id | 404 | rooms.service.ts:44 |
| 4.5 | PATCH type invalid | 400 | create-room.dto.ts:25-28 |
| 4.6 | Update reflection in list/detail | fresh GET shows change (no stale cache server-side) | base.repository.ts:57-66 |
5. CRUD — delete (E5)
| # | Check | Expected | Source |
|---|---|---|---|
| 5.1 | DELETE existing | 200, no payload; doc isDeleted=true, deletedAt set, version +1 | rooms.service.ts:48-51; base.repository.ts:68-74 |
| 5.2 | GET after delete | 404 (soft-deleted hidden) | base.repository.ts:20-30 |
| 5.3 | DELETE twice | second → 404 | rooms.service.ts:50 |
| 5.4 | DELETE unknown / other-tenant id | 404 | rooms.service.ts:50; base.repository.ts:24-29 |
| 5.5 | DELETE room referenced by timetable/booking | KNOWN-GAP: succeeds — no in-use guard (OQ-4); guard (planned) | rooms.service.ts:48-51 |
| 5.6 | Re-create same code after delete | 201 (unique index only blocks live docs; isDeleted doc remains) | room.schema.ts:38; base.repository.ts:20-30 |
6. Duplicates (create-path contract)
| # | Check | Expected |
|---|---|---|
| 6.1 | Same code, same tenant, different case (LAB-02 vs lab-02) | KNOWN-GAP: both persist — index is case-sensitive; client uppercases (proposed); normalized index (planned) (OQ-5/1) |
| 6.2 | Trimmed equality (" A-1 " vs "A-1") | both persist (trim happens at write: room.schema.ts:21-22 — DB stores trimmed; two docs only if created pre-trim) — verify via API |
7. Capacity validation
| # | Check | Expected |
|---|---|---|
| 7.1 | Client input < 1 | blocked with hint (proposed) (OQ-7) |
| 7.2 | Client non-numeric | blocked via numeric keyboard + regex (proposed) |
| 7.3 | Server accepts negative today | KNOWN-GAP (documented; @Min(1) (planned)) |
| 7.4 | Absent capacity | no badge shown; never "Cap 0" |
8. Permission & isolation
| # | Check | Expected |
|---|---|---|
| 8.1 | rooms.read/create/update/delete present in permission catalog | yes — permissions.constants.ts:50-53 |
| 8.2 | Server enforces them | KNOWN-GAP: only JwtAuthGuard (OQ-2, rooms.controller.ts:19); RBAC guard (planned) |
| 8.3 | Client hides create/edit/delete without perms | yes (proposed) — per 12's client contract |
| 8.4 | Tenant isolation on all 5 endpoints | structural (scope in base.repository.ts:20-30) — test with two tenants |
| 8.5 | Platform admin scope bypass | intentional (base.repository.ts:21-23) — platform-only tooling |
9. UX / offline / edge
| # | Check | Expected |
|---|---|---|
| 9.1 | Infinite scroll dedupe | no duplicate rows across pages (dedupe by _id) |
| 9.2 | Pull-to-refresh bypasses cache | fresh page=1 |
| 9.3 | Offline list | cached rows + banner; load-more blocked |
| 9.4 | Offline submit | blocked + draft kept |
| 9.5 | 404 on detail | "Room not found" state, not generic error |
| 9.6 | Empty list | "No rooms yet" + create CTA (gated) |
| 9.7 | Filter no-match | "No rooms match your filters" + clear |
| 9.8 | Delete typed-confirm mismatch | button disabled until exact name match |
| 9.9 | Discard dirty form | confirm dialog; no silent data loss |
10. Accessibility (spot; full baseline 00-shared/09)
| # | Check |
|---|---|
| 10.1 | RoomCard single semantics label; chips labeled |
| 10.2 | Field errors in live regions; focus first invalid |
| 10.3 | Contrast on chips/badges ≥ 4.5:1; touch targets ≥ 44 dp |
| 10.4 | disableAnimations honored (no essential motion) |
11. Performance (budgets 00-shared/10 §1)
| # | Check | Target |
|---|---|---|
| 11.1 | List p95 (server) | < 300 ms (find + count on indexed scope) |
| 11.2 | Detail p95 | < 250 ms |
| 11.3 | Infinite scroll jank | no layout jank; ListView.builder |
| 11.4 | Debounces | search + code-hint 300 ms |
12. Regression suite (minimum)
- Unit:
RoomsService— dup create (409), 404s (findById/update/remove), pagination math (buildPaginationMeta—pagination-query.dto.ts:41-54). - E2E: create → list → get → update → delete → get(404) → list excludes; two-tenant isolation; duplicate code 409.
- Widget
(proposed): list load/empty/error, editor 409 inline, delete typed-confirm, permission-gated FAB.
15 — Flutter Implementation Guide (Rooms Module)
- 1. Structure
- 2. Models & enums (exact from schema)
- 3. Repositories
- 4. Cubits (see
13) - 5. Navigation (go_router)
- 6. Theme
- 7. Extensions
- 8. Localization
- 9. Testing
- 10. Performance
- 11. Open items to wire when backend lands
Module extension of 00-shared/11 (Flutter App Architecture). Structure, widgets, cubits, repositories, DTOs, models, navigation, theme, extensions, localization, testing, performance. Forward-looking.
1. Structure
lib/features/rooms/
├── data/
│ ├── dto/
│ │ ├── room_dto.dart # envelope payload → model (fromJson/toJson)
│ │ └── room_create_dto.dart # mirrors CreateRoomDto (create-room.dto.ts:11-38)
│ ├── models/
│ │ └── room.dart # domain model + RoomType enum
│ └── repositories/
│ └── rooms_repository.dart # E1–E5 (12_API_Mapping)
├── domain/
│ └── room_code.dart # pure code-normalization (uppercase/trim) `(proposed)`
└── presentation/
├── cubit/
│ ├── room_list_cubit.dart # PaginatedListMixin<Room>
│ ├── room_detail_cubit.dart
│ └── room_editor_cubit.dart
├── pages/
│ ├── room_list_page.dart # S1
│ ├── room_detail_page.dart # S2
│ └── room_editor_page.dart # S3 (create + edit)
└── widgets/
├── room_card.dart
├── room_type_icon.dart
├── capacity_badge.dart
├── facility_chips.dart
├── room_filter_bar.dart
├── room_type_picker.dart
├── room_code_field.dart
├── facility_input_chips.dart
└── delete_room_dialog.dart
2. Models & enums (exact from schema)
enum RoomType { classroom, lab, library, office, hall, other } // room.schema.ts:7-14
Room mirrors room.schema.ts:16-35 + base.schema.ts: id, tenantId, name, code,
capacity?, type (default classroom), building?, facilities: List<String>,
createdBy?/updatedBy?, isDeleted, deletedAt?, deletedBy?, version, createdAt,
updatedAt. Ids String; dates DateTime (parse ISO). Never send DTOs to widgets
(00-shared/11 §4).
3. Repositories
RoomsRepository (dio via AppDio bearer/refresh/error interceptors — 00-shared/11 §5):
Future<Paginated<Room>> list({int page = 1, int limit = 20}); // E2
Future<Room> getById(String id); // E3
Future<Room> create(RoomCreateDto dto); // E1
Future<Room> update(String id, RoomCreateDto dto); // E4
Future<void> delete(String id); // E5 (void!)
- E5 returns void because the handler is void (
rooms.service.ts:48) — local row removal after 200, never a payload. - Typed exceptions:
ApiException(code, status, fieldDetails, message)from the error interceptor (00-shared/06 §5);DUPLICATE_RESOURCE(409) handled as field error in the editor.
4. Cubits (see 13)
RoomListCubit (PaginatedListMixin + client filters (proposed)), RoomDetailCubit
(404 → NotFound state), RoomEditorCubit (form + dirty + 409-inline). All pure-Dart,
DI via get_it lazy factories (00-shared/11 §2).
5. Navigation (go_router)
GoRoute(path: '/rooms', builder: RoomListPage, guards: [authGuard, permissionGuard('rooms.read')]),
GoRoute(path: '/rooms/new', builder: RoomEditorPage(create), guards: [authGuard, permissionGuard('rooms.create')]),
GoRoute(path: '/rooms/:id', builder: RoomDetailPage, guards: [authGuard, permissionGuard('rooms.read')]),
GoRoute(path: '/rooms/:id/edit', builder: RoomEditorPage(edit), guards: [authGuard, permissionGuard('rooms.update')]),
Permission guards mirror permissions.constants.ts:50-53 (rooms.read/create/update/delete).
Server does not enforce these today (OQ-2 — rooms.controller.ts:19); the client guard
is the only gate until the backend RBAC guard lands; server remains authoritative later
(403 → 403 screen). Master-detail via StatefulShellBranch at ≥ 840 dp (00-shared/05 §3).
Deep links (forward-looking): studylyon://rooms, studylyon://rooms/:id.
6. Theme
Standard AppTheme tokens (00-shared/02, 11); type-tinted icons via
colorScheme.*Container variants (proposed). No literal colors in widgets
(02 §10, 04 §7).
7. Extensions
Reuse shared (00-shared/11 §8): DateTime.toDisplayDate, context.showAppSnackbar, etc.
Module additions: RoomType.displayName, RoomType.iconData (→ RoomTypeIcon),
Room.capacityLabel ("Cap 40" / absent), String.normalizeRoomCode (uppercase + trim —
wraps domain/room_code.dart).
8. Localization
Keys under features/rooms/ namespace in .arb (en + fr + hi smoke): rooms.title,
rooms.list.empty, rooms.list.filter.empty, rooms.detail.notFound, rooms.editor.save,
rooms.editor.code.duplicate, rooms.editor.capacity.min, rooms.delete.confirm.title,
rooms.delete.confirm.typeName, rooms.snackbar.{created,updated,deleted,notFound},
rooms.offline. Server messages rendered via error-code→key map with business-4xx fallback
(00-shared/11 §9, 07 §11).
9. Testing
| Layer | Coverage |
|---|---|
| Unit | room_code normalization vectors ("lab 2" → "LAB 2"); enum mapping; capacityLabel; pagination meta mapping (pagination-query.dto.ts:41-54) |
| Cubit | RoomListCubit pagination + dedupe + filter reset; RoomDetailCubit 404→NotFound; RoomEditorCubit 409-inline + dirty/discard |
| Widget | S1 loading/empty/error + filter chips; S3 code-duplicate hint; S4 typed-confirm disabled state; permission-gated FAB hidden |
| Golden | 3 screens × light/dark × 3 sizes; new components (07 §Golden) |
| Integration | create → list → detail → edit → delete → 404; duplicate-code journey |
| E2E (device cloud) | P0: admin creates room, edits, deletes; read-only role sees list without FAB |
Run: flutter analyze, flutter test, flutter test integration_test (00-shared/11 §12).
10. Performance
ListView.builderfor S1;RepaintBoundaryaround RoomCards;AnimatedSizefor facility expand.- Debounces: search 300 ms, code-duplicate hint 300 ms.
- Caches keyed
sl:{tenant}:rooms:...with TTLs from13 §6;RefreshIndicatorbypasses cache. - Profile against
00-shared/10 §1budgets (list ≤ 300 ms p95, detail ≤ 250 ms).
11. Open items to wire when backend lands
- Server filter/sort/search on
GET /rooms(OQ-3) — drop client-sideRoomFilterBarfiltering, move to query params; addsort/qsupport. - Update-path duplicate check (OQ-1) — remove client-only warning; rely on 409;
map
DUPLICATE_RESOURCEinline on edit. - RBAC guard on controller (OQ-2) — server becomes authoritative; keep client guards as UX, handle real 403.
update-room.dto.ts(OQ-6) — partial PATCH; editor sends only dirty fields.- Capacity bounds (OQ-7) — server
@Min(1); client min mirrors exactly. - In-use delete guard (OQ-4) — delete dialog shows "in use by N timetables/bookings" with server truth; blocked delete → snackbar.
- Bookings module
(planned)— availability section + Book CTA on S2; bookings cubit + endpoints. - QR signage
(forward-looking)—QRCodeCardon S2 + scan route; data already present (room.code,room.schema.ts:21-22). - Utilization analytics
(proposed)— reports surface reading bookings aggregates. - WS
rooms.updated(forward-looking)— cache invalidation on multi-device edits.
01 — Product Overview (Scheduler Module)
- 1. What the module is
- 2. Core domain facts (from source)
- 3. Who uses it
- 4. Scope in / out
- 5. PRD native-app exclusion (flagged)
- 6. Client surface (screens) — see 05/06
- 7. Tenant context handling
The Scheduler is the recurring-job engine of StudyLyon. It registers BullMQ repeatable jobs at boot and fans them out to per-domain queues. Derived from
src/modules/scheduler/**(module, service, controller, DTO,jobs/*.job.ts),src/infrastructure/bullmq/(queue constants, module, DLQ setup),src/infrastructure/workers/*.worker.ts, anddocs/IMPLEMENTATION_PLAN.md. Nothing here is invented; plan-only capability is marked(planned), roadmap-only(forward-looking), analytics(proposed). Shared platform context: 00-shared/01.
1. What the module is
An internal infrastructure module whose job is to keep recurring work happening: daily fee reminders, attendance report generation, cache rebuilds, audit flushing, admission lifecycle scans, and retention cleanup. It has no user-facing feature surface — its "users" are other modules' queues and the platform operator who keeps it healthy.
The mechanism is BullMQ repeatable jobs, not @Cron: on module init the
service registers 10 default repeatable jobs onto 10 queues
(scheduler.service.ts:47-126), each queue.add(..., { repeat: { pattern, tz } })
(scheduler.service.ts:135-147). Registration is idempotent — an existing
job with the same name+pattern is skipped (scheduler.service.ts:128-133).
2. Core domain facts (from source)
- Trigger model — a repeatable job is
{ name, pattern, queue, queueName, tz }(scheduler.service.ts:11-18). Every job runs in UTC (:54,:62,:70,:76,:82,:89,:96,:103,:110,:117). - Job payload convention — every trigger enqueues
{ eventType, tenantId: 'system', correlationId: crypto.randomUUID(), actorId: 'scheduler' }(scheduler.service.ts:136-142).tenantId: 'system'means triggers are tenant-neutral; tenant scoping happens in the workers (see §7). - Retention on triggers —
removeOnComplete: { age: 3600, count: 100 },removeOnFail: { age: 86400 * 7 }(scheduler.service.ts:145-146). - Default schedules (all UTC,
scheduler.service.ts:48-119):
| Job name | Cron pattern | Meaning | Target queue |
|---|---|---|---|
overdue-scan | 0 6 * * * | daily 06:00 | invoice-generate |
daily-digest | 0 9 * * * | daily 09:00 | emails |
dashboard-rebuild | */5 * * * * | every 5 min | cache-rebuild |
biometric-sync | */15 * * * * | every 15 min | biometric-sync |
audit-flush | */1 * * * * | every minute | audit-write |
retention-archive | 0 2 * * 0 | Sun 02:00 | tenant-purge |
fee-reminder | 0 8 * * * | daily 08:00 | payment-reminder |
attendance-report-daily | 0 7 * * * | daily 07:00 | report-generate |
admission-reminder-scan | 0 8 * * * | daily 08:00 | admission-reminder |
admission-expiry-scan | 0 2 * * * | daily 02:00 | admission-expiry |
- Fan-out jobs — four job classes exist
(
scheduler.module.ts:15-22):OverdueScanJob(jobs/overdue-scan.job.ts:12-19, enqueuescheck-overdue),DailyDigestJob(jobs/daily-digest.job.ts:12-25,send-daily-digest),FeeReminderJob(jobs/fee-reminder.job.ts:16-47, onesend-payment-reminderper invoice due within 3 days),AttendanceReportJob(jobs/attendance-report.job.ts:13-28,generate-attendance-report, typedaily|weekly). - Custom schedules — any 5-field cron on 11 whitelisted queues
(
dto/create-schedule.dto.ts:4-28), created viaPOST /scheduler(scheduler.service.ts:198-208). - Admission lifecycle — two dedicated queues exist today:
admission-reminderandadmission-expiry(queue.constants.ts:15-16), with workersAdmissionReminderWorker/AdmissionExpiryWorker(bullmq.module.ts:18-20); admission workflow states (documents pending, interview) are(planned)perIMPLEMENTATION_PLAN.md:53-78.
3. Who uses it
| Actor | Relationship |
|---|---|
| Platform operator / superadmin | Owns scheduler health: list jobs, remove broken schedules, create custom schedules (scheduler.controller.ts:23-46) |
| School IT admin | Receives scheduled outputs (daily digest, attendance reports, fee reminders) for their tenant |
| Other modules | Their workers consume scheduler-triggered jobs off the shared queues |
| End users (students/parents/teachers) | Only via delivered outputs (emails, reports, reminders) — never touch the scheduler |
4. Scope in / out
| In scope (implemented) | Out of scope / gaps |
|---|---|
| 10 default repeatable jobs registered at boot | Per-tenant cron scheduling (triggers are tenantId: 'system', scheduler.service.ts:139) |
List / create / remove repeatable jobs (scheduler.controller.ts:23-46) | Run history, last-run/failed timestamps, logs — console (proposed), no endpoint |
| Manual trigger of a schedule | Job logs / DLQ viewer UI — (proposed) |
Custom cron + queue whitelist (create-schedule.dto.ts:4-16) | whatsapp, in-app, attendance-process, webhook-deliver are queues but not in the create whitelist (dto:4-16) |
Retry + DLQ on queues (bullmq.module.ts:60-65, dlq.setup.ts:5-27) | Report template scheduling — (planned) (IMPLEMENTATION_PLAN.md:233) |
Retention trigger (retention-archive) | Retention of documents (soft-delete cleanup, GDPR erasure, cold storage) — (planned) (IMPLEMENTATION_PLAN.md:176) |
| Admission reminder/expiry scans | Webhook /retry /test /metrics /pause — (planned) (IMPLEMENTATION_PLAN.md:177) |
5. PRD native-app exclusion (flagged)
Per 00-shared/01 §9: PRODUCT_REQUIREMENTS_DOCUMENT.md:144 puts native
mobile apps out of Phase 1 scope (roadmap Phase 3 plans a read-only
companion). The decision recorded with the product owner is that these
docs specify a Flutter admin console now, to the scheduler's (proposed)
console API surface; any conflict with the web-first roadmap resolves in
favor of these docs unless the roadmap is amended.
6. Client surface (screens) — see 05/06
Scheduled Jobs console — all (proposed): Jobs list · Job detail /
run history · Create custom schedule · Manual trigger · Job logs · DLQ viewer.
The only screens backed by real endpoints today are list/create/remove
(scheduler.controller.ts:23-46).
7. Tenant context handling
Workers restore tenant context with tenantContext.run({...}, () => work)
(report.worker.ts:18-30, finance.worker.ts:27-29, attendance.worker.ts:27-29,
admission-reminder.worker.ts:30-32, admission-expiry.worker.ts,
inapp.worker.ts:19-21). Gap: FeeReminderJob queries
InvoiceRepository directly (fee-reminder.job.ts:20-23) without wrapping
itself in tenantContext.run — tenant scoping of that query depends on
whatever context is ambient in the scheduler process, while trigger payloads
carry tenantId: 'system'. See 14_QA (tenant isolation).
02 — User Personas (Scheduler Module)
- Persona 1 — The Platform Operator ("Nadia")
- Persona 2 — The School IT Admin ("Mr. Okonkwo")
- Persona 3 — The On-Call Engineer ("Priya")
- Persona 4 — The Domain Module Owner ("Dev")
- Non-persona: End Users (students / parents / teachers)
- Shared profile notes
The scheduler is infrastructure: its users are the operators who keep recurring work running. Personas are derived from the access model (
scheduler.controller.ts:16-24—JwtAuthGuard+Permissions) and from how the module is consumed (scheduler.service.ts,jobs/*.job.ts,infrastructure/workers/*). No end-user persona exists by design — the PRD native-app exclusion applies (see 01 §5).
Persona 1 — The Platform Operator ("Nadia")
Role: Platform superadmin at StudyLyon HQ. Owns the multi-tenant deployment; the only actor with cross-tenant visibility.
| Attribute | Detail |
|---|---|
| Goal | All recurring work fires on time, every tenant |
| Pain | Silent failures: a digest that never sends is noticed by schools, not by logs |
| Tools today | GET /scheduler list + Redis/BullMQ CLI inspection; no run history (gap → console (proposed)) |
| Authority | scheduler.read / scheduler.create / scheduler.delete permissions (scheduler.controller.ts:24,31,38) |
| Behaviors | Checks the job list at deploy time; watches for missed runs after deploys or Redis restarts |
| Wants | Last-run/failed timestamps, one-click manual trigger, DLQ visibility, alerting on missed runs |
| Fears | BullMQ repeatable jobs silently deregistered on a redeploy; backlog storms after a long outage |
Persona 2 — The School IT Admin ("Mr. Okonkwo")
Role: IT administrator of one school tenant. Does not touch the platform scheduler itself; consumes its outputs.
| Attribute | Detail |
|---|---|
| Goal | Parents/teachers get the daily digest, attendance reports, and fee reminders they expect |
| Pain | Reports arrive late or not at all after school-hours (07:00 UTC is early for some timezones) |
| Surface | None directly — receives emails (emails queue), report outputs (report-generate) |
| Behaviors | Files tickets like "today's attendance report never arrived" |
| Wants | A per-tenant "scheduled reports" view with delivery status (proposed) |
| Fears | Missed report = parents complaining; fee reminder missed = cashflow friction |
Persona 3 — The On-Call Engineer ("Priya")
Role: Backend engineer on rotation. Primary responder for queue/DLQ incidents.
| Attribute | Detail |
|---|---|
| Goal | Diagnose a missed or failed job in minutes |
| Pain | Repeatable triggers are fire-and-forget; failure detail lives in BullMQ + DLQ internals (dlq.setup.ts:5-27) |
| Tools today | BullMQ dashboard, Redis CLI, logs |
| Behaviors | Greps job names (check-overdue, send-daily-digest, …); checks attemptsMade vs attempts |
| Wants | Job logs screen, DLQ viewer with retry, idempotency check info |
| Fears | Replaying a job that was already half-processed (double sends) |
Persona 4 — The Domain Module Owner ("Dev")
Role: Developer of fees/attendance/admissions modules that register jobs and consume the queues.
| Attribute | Detail |
|---|---|
| Goal | Their module's async work is triggered reliably without re-inventing cron |
| Surface | Register via SchedulerService injection or repeatable queue.add; consume via workers |
| Behaviors | Adds eventType to event-queue-map.ts for event-driven paths, or a repeatable job for scheduled paths |
| Wants | A documented contract: payload envelope (JobData), tenant restoration in workers, DLQ on failure |
Non-persona: End Users (students / parents / teachers)
No scheduler surface. They interact only with delivered artifacts
(digests, reminders, reports). Any direct scheduling capability in their
apps is (forward-looking).
Shared profile notes
All authenticated personas share: bearer JWT (scheduler.controller.ts:17),
RBAC via Permissions decorator, and response envelopes per 00-shared/07.
Analytics for console screens is (proposed) — no events exist today
(00-shared/10 §8).
03 — User Journeys (Scheduler Module)
- Journey 1 — First Deployment Bring-Up (console-free)
- Journey 2 — Daily Health Check
- Journey 3 — Missed-Run Recovery (after outage / redeploy)
- Journey 4 — Fee Reminder Pipeline Check (console-free)
- Journey 5 — Custom Schedule Lifecycle (operator-created)
- Journey 6 — Incident Postmortem
- Journey map
- Happiness criteria (all journeys)
Journeys are written for the operator/admin personas (02). Screens referenced are the console
(proposed)set (05/06); steps backed by real endpoints are tagged with theirfile:line. Flows that need no UI today (journey 1, 4) are marked "console-free".
Journey 1 — First Deployment Bring-Up (console-free)
Persona: Platform Operator · Trigger: fresh deploy of the API
SchedulerModuleboots;SchedulerService.onModuleInitfires (scheduler.service.ts:43-45).registerDefaults()iterates 10 jobs; each is skipped if an identicalname+patternrepeatable already exists (scheduler.service.ts:128-133).- New jobs are added with
repeat: { pattern, tz: 'UTC' }(scheduler.service.ts:135-147). - Operator verifies:
GET /schedulerreturns the 10 jobs (scheduler.controller.ts:23-28). - Success = log line
Registered 10 default repeatable jobs(scheduler.service.ts:125).
Exit: all 10 rows visible; next occurrence times sane in UTC.
Journey 2 — Daily Health Check
Persona: Platform Operator · Trigger: start of day
- Open Jobs list screen
(proposed)—GET /schedulertoday (scheduler.controller.ts:23-28), plus last-run/failed timestamps(proposed). - Scan status badges: 10 default jobs + any custom ones.
- Spot-check
audit-flush(*/1 * * * *) — a missed minute mark signals Redis/worker trouble. - Drill into a failed row → Job detail with run history
(proposed). - If a run failed 3× it lands in the DLQ (
dlq.setup.ts:8-9) → DLQ viewer(proposed).
Exit: all green, or a ticket opened with failedReason from the DLQ.
Journey 3 — Missed-Run Recovery (after outage / redeploy)
Persona: On-Call Engineer · Trigger: "schools say no digest today"
- Confirm the repeatable survived the redeploy:
GET /scheduler(scheduler.controller.ts:23-28) — repeatables can vanish if Redis was flushed, so re-registration depends on boot order (scheduler.service.ts:43-45). - Check BullMQ/Redis for the
0 9 * * *daily-digestentry (scheduler.service.ts:56-62). - Manual trigger
(proposed)— today only possible by re-adding orqueue.add('send-daily-digest', ...)from a script (daily-digest.job.ts:12-25). - Confirm the worker picked it up and restored tenant context
(
report.worker.ts:18-30pattern). - Idempotency risk: a manual re-trigger must not double-send — worker
idempotency via
IdempotencyService(bullmq.module.ts:76) is the guard.
Exit: digest delivered once; root cause (Redis flush, worker down)
recorded; alerting rule (proposed) added.
Journey 4 — Fee Reminder Pipeline Check (console-free)
Persona: Platform Operator · Trigger: end of month
fee-reminderfires at 08:00 UTC (scheduler.service.ts:92-97).FeeReminderJobscans invoices due within 3 days, statusISSUED|PARTIAL(fee-reminder.job.ts:16-23).- One
send-payment-reminderper invoice enqueued (fee-reminder.job.ts:25-41) withamount = totalAmount − paidAmount(:37). - Worker sends; failures retry 3× exponential 5 s (
bullmq.module.ts:60-65), then DLQ (dlq.setup.ts:8-21).
Exit: reminder count matches due-invoice count; DLQ empty.
Journey 5 — Custom Schedule Lifecycle (operator-created)
Persona: Platform Operator · Trigger: "rebuild dashboards off-peak"
- Create:
POST /schedulerwith{pattern, queue, jobName, payload?, tz?}(scheduler.controller.ts:30-35); cron validated as 5 fields (create-schedule.dto.ts:20-24), queue whitelisted (dto:26-28). - Verify:
GET /schedulershows the new row (scheduler.controller.ts:23-28). - Later, remove:
DELETE /scheduler?queue=&name=&pattern=(scheduler.controller.ts:37-46) →removeRepeatable(..., { pattern, tz: 'UTC' })(scheduler.service.ts:188-196).
Exit: row gone; no ghost repeats (BullMQ dedupes by name+pattern+queue).
Journey 6 — Incident Postmortem
Persona: On-Call Engineer · Trigger: a tenant missed attendance-report-daily
- Identify schedule:
0 7 * * *→report-generate(scheduler.service.ts:98-104). - Job detail
(proposed): last runs,failedReason. - DLQ record:
originalQueue, originalJobId, failedReason, attemptsMade(dlq.setup.ts:12-21). - Replay from DLQ
(proposed); verify idempotency before retry. - Patch the root cause; amend QA checklist (14).
Exit: documented timeline + fix; report re-delivered.
Journey map
deploy ─► bring-up ─► daily check ─► incident ─► recovery ─► postmortem
│ │ │ │ │ │
register GET /scheduler badges, manual re-trigger DLQ replay
defaults 10 jobs DLQ scan trigger (idempotent) + fix
Happiness criteria (all journeys)
- Any job's next run is predictable from the schedule table (01 §2).
- A failed job is visible within one polling cycle (
audit-flushruns every minute,scheduler.service.ts:78-83). - Recovery never double-delivers (idempotency gate).
04 — Information Architecture (Scheduler Module)
- 1. Backend IA — module composition
- 2. Job taxonomy
- 3. Console IA — Scheduled Jobs (superadmin,
(proposed)) - 4. Relationship to other modules
- 5. Navigation & routing
Backend: the module's runtime structure (queues ⇄ triggers). Console: the operator-facing IA of the Scheduled Jobs admin area —
(proposed)unless an endpoint is cited. The console is a platform-level (superadmin) surface, sibling of Security/API Keys in the admin settings cluster.
1. Backend IA — module composition
SchedulerModule (scheduler.module.ts:12-23)
├── SchedulerController GET/POST/DELETE /scheduler (scheduler.controller.ts:19-46)
├── SchedulerService onModuleInit → registerDefaults (scheduler.service.ts:43-45)
│ ├── 10 repeatable jobs ──► 10 queues (scheduler.service.ts:48-119)
│ └── listJobs / createJob / removeJob (scheduler.service.ts:152-226)
├── Jobs (providers, scheduler.module.ts:15-22)
│ ├── OverdueScanJob → queue.add('check-overdue') (overdue-scan.job.ts:12-19)
│ ├── DailyDigestJob → queue.add('send-daily-digest') (daily-digest.job.ts:12-25)
│ ├── FeeReminderJob → InvoiceRepository scan + per-invoice enqueue (fee-reminder.job.ts:16-47)
│ └── AttendanceReportJob → queue.add('generate-attendance-report') (attendance-report.job.ts:13-28)
└── CreateScheduleDto cron + queue whitelist (dto/create-schedule.dto.ts:18-41)
Queue registry: 15 queues + DLQ registered in BullMqModule
(bullmq.module.ts:28-45); names in queue.constants.ts:1-17; event-driven
routes in event-queue-map.ts:6-43 (scheduler triggers bypass the map and
enqueue directly by queue.add).
2. Job taxonomy
| Kind | Example | Source |
|---|---|---|
| Default repeatable (10) | overdue-scan, daily-digest, dashboard-rebuild, biometric-sync, audit-flush, retention-archive, fee-reminder, attendance-report-daily, admission-reminder-scan, admission-expiry-scan | scheduler.service.ts:48-119 |
| Custom repeatable (operator) | any 5-field cron on 11 whitelisted queues | create-schedule.dto.ts:4-16 |
| Fan-out child jobs | check-overdue, send-daily-digest, send-payment-reminder, generate-attendance-report | jobs/*.job.ts |
| Event-driven jobs | send-welcome-email, log-login, … | event-queue-map.ts:6-43 |
3. Console IA — Scheduled Jobs (superadmin, (proposed))
Admin Settings
└── Scheduled Jobs (/admin/scheduler)
├── Jobs List (S1) → GET /scheduler ✓ today
│ ├── Job Detail (S2) run history + next run (proposed)
│ │ └── Job Logs (S4) per-run log stream (proposed)
│ └── Create Schedule (S3) POST /scheduler ✓ today
└── Dead Letter Queue (S6) failed-after-retry rows (proposed)
└── DLQ Record detail: originalQueue/jobId, failedReason, attemptsMade (dlq.setup.ts:12-21)
Content model of a job row (from scheduler.service.ts:152-186):
queue • name • pattern • tz
+ (proposed) lastRunAt • lastStatus • nextRunAt • runs24h • avgDurationMs
Content model of a DLQ row (from dlq.setup.ts:12-21):
originalQueue • originalJobId • originalJobName • data
failedReason • attemptsMade • failedAt
4. Relationship to other modules
| Module | Through | Direction |
|---|---|---|
| Fees | invoice-generate, payment-reminder queues; FeesModule import (scheduler.module.ts:13) | scheduler triggers, fees processes |
| Attendance / Reports | attendance-process, report-generate | scheduler triggers, workers process |
| Notifications | emails, in-app, push | scheduler triggers digests/reminders |
| CRM | admission-reminder, admission-expiry | scheduler scans lifecycle |
| Audit | audit-write | audit-flush cadence |
| Infra | Redis (BullMQ), DLQ (dlq.setup.ts), IdempotencyService (bullmq.module.ts:76) | delivery guarantees |
5. Navigation & routing
- Console root:
/admin/scheduler(nested under admin settings). - Job detail:
/admin/scheduler/jobs/:queue/:name(queue+name disambiguate —fee-reminder≠attendance-report-daily, both daily patternsscheduler.service.ts:92-104). - DLQ:
/admin/scheduler/dlq+/admin/scheduler/dlq/:jobId. - Existing endpoints stay under
/api/v1/scheduler(scheduler.controller.ts:19— URI versioning frommain.ts).
05 — Screen Inventory (Scheduler Module)
- S1 — Scheduled Jobs List (
/admin/scheduler)(proposed) - S2 — Job Detail / Run History (
/admin/scheduler/jobs/:queue/:name)(proposed) - S3 — Create Custom Schedule (sheet)
(proposed) - S4 — Job Logs (
/admin/scheduler/jobs/:queue/:name/logs)(proposed) - S5 — Manual Trigger (dialog)
(proposed) - S6 — Dead Letter Queue (
/admin/scheduler/dlq)(proposed) - Shared components used
- Analytics events (proposed)
- Keyboard, landscape, tablet, desktop
Every screen of the Scheduled Jobs admin console, its intent, route, composition, states, permissions and events. All console screens are
(proposed)— they are specified for the operator UX; only the three endpoints behind them exist (scheduler.controller.ts:23-46). Authoritative components in 00-shared/03; this file enumerates which each screen uses with module specifics. Legend — States: idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission. Analytics:scheduler.{screen}.{action}(proposed)(00-shared/10 §8).
S1 — Scheduled Jobs List (/admin/scheduler) (proposed)
| Field | Detail |
|---|---|
| Purpose | Inventory of every repeatable job across all queues |
| Entry | admin settings → Scheduled Jobs; cold app deep link |
| Exit | → job detail (S2); → create sheet (S3); → DLQ (S6) |
| Source | GET /scheduler → {queue, name, pattern, tz}[] (scheduler.service.ts:152-186) — plus (proposed) lastRun/status enrichment |
| Widgets | AppListTile rows: job name, queue chip, cron chip, tz chip, status badge; summary header counts |
| Row actions | Tap → S2; AppMenu: "Run now" (S5), "View logs" (S4), "Remove" (confirm) |
| States | loading (AppSkeleton), empty ("No scheduled jobs — defaults register at boot" scheduler.service.ts:43-45), error (offline / 403 permission scheduler.read scheduler.controller.ts:24) |
| Analytics | scheduler.list.open, scheduler.list.refresh, scheduler.job.open |
| Refresh | RefreshIndicator; auto-poll every 60 s (audit-flush cadence scheduler.service.ts:78-83) |
| a11y | badge colors never sole signal (icon + text), live region on status change |
S2 — Job Detail / Run History (/admin/scheduler/jobs/:queue/:name) (proposed)
| Field | Detail |
|---|---|
| Purpose | One job's schedule, definition, and recent runs |
| Source | schedule from GET /scheduler; runs (proposed) GET /scheduler/runs?queue=&name= |
| Composition | header (name, pattern, tz, queue), run-history list (startedAt, status, duration, error), schedule meta (removeOnComplete/removeOnFail scheduler.service.ts:145-146) |
| States | empty ("no runs in retention window — triggers keep 1 h / 100 completed scheduler.service.ts:145") |
| CTAs | "Run now" (S5), "View logs" (S4), "Remove schedule" |
| Analytics | scheduler.detail.open, scheduler.detail.run |
S3 — Create Custom Schedule (sheet) (proposed)
| Field | Detail |
|---|---|
| Purpose | Operator-defined cron on a whitelisted queue |
| Source | POST /scheduler (scheduler.controller.ts:30-35); DTO contract create-schedule.dto.ts:18-41 |
| Form | queue (enum of 11, dto:4-16), jobName (free text), pattern (5-field cron, regex dto:20-24), tz (default UTC), payload (JSON, optional) |
| States | inline cron error ("Invalid cron pattern (5 fields required)" dto:22), success → row appears in S1 |
| Analytics | scheduler.create.submit, scheduler.create.success, scheduler.create.failure(reason) |
S4 — Job Logs (/admin/scheduler/jobs/:queue/:name/logs) (proposed)
| Field | Detail |
|---|---|
| Purpose | Per-run log stream for a job (operator + on-call diagnosis) |
| Source | (proposed) GET /scheduler/jobs/:queue/:name/logs |
| Composition | AppListTile log lines (timestamp, level, message, jobId), filter by level, tail-live toggle |
| States | empty ("no logs kept — job data retention is 1 h completed / 7 d failed scheduler.service.ts:145-146"), error |
| Analytics | scheduler.logs.open, scheduler.logs.filter |
S5 — Manual Trigger (dialog) (proposed)
| Field | Detail |
|---|---|
| Purpose | Fire a repeatable job's child job now, once |
| Source | (proposed) POST /scheduler/:queue/:name/trigger → re-enqueues child job per jobs/*.job.ts contract |
| Composition | confirm dialog: job name, target queue, warning text "deliveries may duplicate — verify idempotency" |
| States | success → snackbar + row lastRun updates; disabled while a run is in flight |
| Analytics | scheduler.trigger.submit, scheduler.trigger.success, scheduler.trigger.failure |
S6 — Dead Letter Queue (/admin/scheduler/dlq) (proposed)
| Field | Detail |
|---|---|
| Purpose | Jobs that exhausted retries: attemptsMade >= attempts → DLQ (dlq.setup.ts:8-9) |
| Source | (proposed) GET /scheduler/dlq; record shape dlq.setup.ts:12-21 |
| Widgets | AppCard per DLQ record: originalQueue, originalJobName, failedReason, attemptsMade, failedAt; actions Replay / Delete (confirm) |
| Empty | "No dead letters" — success state |
| Analytics | scheduler.dlq.open, scheduler.dlq.replay, scheduler.dlq.delete |
Shared components used
AppListTile, AppCard, AppChips (queue/cron), AppBadge (status),
AppButton, AppMenu, AppDialog, AppBottomSheet, AppSnackbar,
AppSkeleton, AppEmptyState, AppOfflineBanner, AppFAB ("New
schedule"), AppTextField (cron/jobName), AppDropdown (queue),
AppSwitch (tail-live). Module-specific: JobStatusBadge, CronChip,
RunHistoryRow, DLQCard — defined in 07_Component_Library.md.
Analytics events (proposed)
scheduler.list.{open,refresh}, scheduler.job.{open,remove},
scheduler.detail.{open,run}, scheduler.create.{submit,success,failure},
scheduler.logs.{open,filter}, scheduler.trigger.{submit,success,failure},
scheduler.dlq.{open,replay,delete}.
Keyboard, landscape, tablet, desktop
- Console is desktop-first (admin tool); tablet uses two-column list/detail.
- Forms in
AppBottomSheet(max-width 640 dp) on all breakpoints. - Cron input: monospace, auto-validate on blur, hint from schedule table (01 §2).
- All fields keyboard-avoidance +
ScrollViewresizeToAvoidBottomInset.
06 — Screen Specifications (Scheduler Module)
- S1 — Scheduled Jobs List
- S2 — Job Detail / Run History
- S3 — Create Custom Schedule
- S4 — Job Logs
- S5 — Manual Trigger Dialog
- S6 — Dead Letter Queue
Detailed specs for the Scheduled Jobs console.
(proposed)markers mean the screen/endpoint does not exist in source; everything else citesfile:line. Data shapes are quoted fromscheduler.service.tsanddlq.setup.ts.
S1 — Scheduled Jobs List
Layout (desktop, ≥ 1024 dp)
┌──────────────────────────────────────────────────────────────┐
│ Admin Settings · Scheduled Jobs [+ New] │
│ 10 default · 2 custom · 0 dead letters (summary strip) │
├──────────────────────────────────────────────────────────────┤
│ [status] job name queue cron tz ⋮ │
│ ● running audit-flush audit-write */1 * * * * UTC ⋮ │
│ ● ok daily-digest emails 0 9 * * * UTC ⋮ │
│ ▲ failed attendance-report-daily report-generate 0 7 * * * │
└──────────────────────────────────────────────────────────────┘
| Spec | Detail |
|---|---|
| Data | GET /scheduler → {queue, name, pattern, tz} (scheduler.service.ts:152-186); enrich (proposed) with lastRunAt, lastStatus, nextRunAt |
| Sort | default: name asc; toggle group by queue (drop-down) |
| Status derivation | (proposed) — from BullMQ job states of last run; today the API has no status field |
| Row tap | → S2 (/admin/scheduler/jobs/:queue/:name) |
| Menu | Run now (S5) · View logs (S4) · Remove — remove calls DELETE /scheduler?queue=&name=&pattern= (scheduler.controller.ts:37-46, scheduler.service.ts:188-196) |
| Empty | "No scheduled jobs — the 10 defaults register on boot (scheduler.service.ts:43-45)" |
| Refresh | pull + auto-poll 60 s; offline → AppOfflineBanner |
| Permission | scheduler.read (403 → AppEmptyState permission variant) |
S2 — Job Detail / Run History
| Spec | Detail |
|---|---|
| Header | name, CronChip(pattern), tz chip, queue chip, status badge |
| Definition block | retention: removeOnComplete {age:3600, count:100}, removeOnFail {age: 7d} (scheduler.service.ts:145-146); global retry: attempts: 3, exponential backoff 5 s (bullmq.module.ts:60-65); trigger payload envelope {eventType, tenantId:'system', correlationId, actorId:'scheduler'} (scheduler.service.ts:136-142) |
| Run history | (proposed) table: startedAt · status (ok/failed/in-progress) · duration · error excerpt. Source (proposed) GET /scheduler/runs?queue=&name= |
| Retention note | history window is 1 h / 100 completed jobs for the trigger, and 14 d for failed jobs globally (bullmq.module.ts:63-64) — display accordingly |
| CTAs | Run now · View logs · Remove schedule (confirm) |
S3 — Create Custom Schedule
| Spec | Detail |
|---|---|
| Trigger | FAB "+ New" (S1) → AppBottomSheet |
| Fields | queue (AppDropdown, 11 whitelisted: create-schedule.dto.ts:4-16), jobName (required, dto:30-32), pattern (5-field cron, dto:20-24; monospace; hint row of preset chips e.g. 0 6 * * *), tz (default UTC dto:38-41), payload (JSON textarea, optional) |
| Validation | inline per field; server re-validates (dto:19-41); pattern regex error message "Invalid cron pattern (5 fields required)" (dto:22) |
| Submit | POST /scheduler (scheduler.controller.ts:30-35) → queue.add with repeat {pattern, tz} (scheduler.service.ts:198-208) |
| Success | sheet closes, snackbar, new row at top (optimistic, rollback on fail) |
| Duplicate | registerRepeatable skips existing name+pattern (scheduler.service.ts:128-133) — UI mirrors: warn if identical row exists |
S4 — Job Logs
| Spec | Detail |
|---|---|
| Route | /admin/scheduler/jobs/:queue/:name/logs |
| Source | (proposed) GET /scheduler/jobs/:queue/:name/logs?level=&tail= |
| Content | timestamp · level · message · jobId; monospace; auto-scroll; "tail live" AppSwitch |
| Filter | level chips (debug/info/warn/error); default info |
| Empty | "No logs kept — completed trigger jobs live 1 h (scheduler.service.ts:145)" |
| Note | worker-side logs exist via nestjs-pino (AGENTS.md stack); surfacing them is (proposed) |
S5 — Manual Trigger Dialog
| Spec | Detail |
|---|---|
| Trigger | row menu "Run now" or S2 CTA |
| Content | confirm: "Trigger daily-digest → send-daily-digest on queue emails now? (Deliveries may duplicate — worker idempotency must hold.)" |
| Action | (proposed) POST /scheduler/:queue/:name/trigger → re-enqueues via job class (daily-digest.job.ts:12-25 pattern) |
| States | in-flight (button spinner, dialog not dismissible twice), success snackbar "Triggered — watch run history", failure inline |
| Idempotency guard | IdempotencyService (bullmq.module.ts:76) must de-dupe on replay — QA item 14 §6 |
S6 — Dead Letter Queue
| Spec | Detail |
|---|---|
| Route | /admin/scheduler/dlq |
| Source | (proposed) GET /scheduler/dlq — records written by setupDlqListener (dlq.setup.ts:5-27) |
| Card fields | originalQueue, originalJobName, originalJobId, failedReason, attemptsMade, failedAt, data (JSON expandable) |
| Actions | Replay → (proposed) POST /scheduler/dlq/:id/retry (re-add to original queue with same data); Delete → confirm dialog |
| Empty | "No dead letters" |
| Note | DLQ condition: attemptsMade >= (job.opts.attempts ?? 3) (dlq.setup.ts:8-9); replay must preserve correlationId for idempotency |
07 — Component Library (Scheduler Module)
- C1 — JobStatusBadge
- C2 — CronChip
- C3 — QueueChip
- C4 — ScheduleSummaryStrip
- C5 — RunHistoryRow
- C6 — JobDefinitionCard
- C7 — DLQCard
- C8 — PresetCronChips (form helper)
- Composition map
- Reuse rules
Module-specific components for the Scheduled Jobs console, mapped to authoritative components in 00-shared/03. Every component here is
(proposed)(console-only); none exist in the Flutter app today.
C1 — JobStatusBadge
| Field | Detail |
|---|---|
| Base | AppBadge (00-shared/03) |
| Variants | ok (green, check icon) · running (blue, pulse) · failed (red, alert icon) · missed (amber, clock icon — no run in expected window) · unknown (grey, "—") |
| a11y | icon + text always (never color alone); tooltip with last run timestamp |
| Data | (proposed) from run-history endpoint; today undeterminable from GET /scheduler (scheduler.service.ts:152-186 returns no status) |
C2 — CronChip
| Field | Detail |
|---|---|
| Base | AppChips (00-shared/03) |
| Content | monospace pattern + humanized summary ("daily 06:00 UTC"), tooltip with full meaning |
| Humanizer map | 0 6 * * * → daily 06:00 · 0 9 * * * → daily 09:00 · */5 * * * * → every 5 min · */15 * * * * → every 15 min · */1 * * * * → every minute · 0 2 * * 0 → Sunday 02:00 · 0 7 * * * → daily 07:00 · 0 8 * * * → daily 08:00 · 0 2 * * * → daily 02:00 (all UTC, scheduler.service.ts:48-119) |
| Invalid | red border + tooltip "Invalid cron pattern (5 fields required)" (create-schedule.dto.ts:22) |
C3 — QueueChip
| Field | Detail |
|---|---|
| Base | AppChips |
| Content | queue name (e.g. report-generate); color-coded per family: delivery (emails, push, whatsapp, in-app), processing (attendance-process, biometric-sync, invoice-generate, payment-reminder, report-generate), system (audit-write, tenant-purge, cache-rebuild, webhook-deliver, admission-reminder, admission-expiry) — names from queue.constants.ts:1-17 |
C4 — ScheduleSummaryStrip
| Field | Detail |
|---|---|
| Base | AppCard (00-shared/03) |
| Content | counts: default (10 — scheduler.service.ts:48-119), custom, dead letters (→ S6). Tap navigates to filtered list |
C5 — RunHistoryRow
| Field | Detail |
|---|---|
| Base | AppListTile (00-shared/03) |
| Content | startedAt (relative + absolute), JobStatusBadge, duration ms, error excerpt (2 lines, error color, monospace) |
| Data | (proposed); fields from BullMQ job state (attemptsMade, finishedOn, failedReason) |
C6 — JobDefinitionCard
| Field | Detail |
|---|---|
| Base | AppCard |
| Content | trigger payload envelope spec {eventType, tenantId:'system', correlationId, actorId:'scheduler'} (scheduler.service.ts:136-142); retention removeOnComplete {age:3600, count:100}, removeOnFail {age: 7d} (scheduler.service.ts:145-146); retry attempts: 3, exponential 5 s (bullmq.module.ts:60-65) |
| Use | S2 definition block — read-only, for operators/on-call |
C7 — DLQCard
| Field | Detail |
|---|---|
| Base | AppCard |
| Content | originalQueue, originalJobName, originalJobId, failedReason (prominent), attemptsMade, failedAt, expandable JSON data (dlq.setup.ts:12-21) |
| Actions | Replay (AppButton), Delete (opens AppDialog confirm) |
C8 — PresetCronChips (form helper)
| Field | Detail |
|---|---|
| Base | AppChips selectable |
| Content | 8 presets matching the defaults (scheduler.service.ts:48-119) — one-tap fill for S3's pattern field; free entry still allowed |
Composition map
| Screen | Components |
|---|---|
| S1 Jobs list | C4 summary, AppListTile + C1 + C2 + C3, AppMenu, AppFAB, AppSkeleton, AppEmptyState, AppOfflineBanner |
| S2 Detail | C6 definition, C5 history rows, C1/C2/C3 header |
| S3 Create | C8 presets, AppTextField, AppDropdown, AppBottomSheet, AppSnackbar |
| S4 Logs | monospace log tiles, AppSwitch tail-live, level chips |
| S5 Trigger | AppDialog confirm |
| S6 DLQ | C7 cards, AppDialog, AppEmptyState |
Reuse rules
- No new design tokens: colors/spacing/type per 00-shared/02.
- All components stateless + controlled (state lives in Blocs, 13_State_Management).
- Everything
(proposed)until the console build phase starts.
08 — Form Specifications (Scheduler Module)
- F1 — Create Custom Schedule (S3)
- F2 — Manual Trigger Confirm (S5,
(proposed)) - F3 — Remove Schedule Confirm (S1/S2)
- F4 — DLQ Replay / Delete Confirms (S6,
(proposed)) - Form inventory
Forms of the Scheduled Jobs console. Only S3 maps to a real endpoint (
POST /scheduler); all other forms belong to(proposed)screens. Validation rules quoted fromdto/create-schedule.dto.ts. Baseline form behavior per 00-shared/03 (AppTextField/AppDropdown/AppBottomSheet).
F1 — Create Custom Schedule (S3)
| Field | Widget | Validation (server) | Notes |
|---|---|---|---|
| Queue | AppDropdown | @IsIn(QUEUE_NAMES) (dto:26-28) | 11 whitelisted queues: emails, push, in-app, attendance-process, biometric-sync, invoice-generate, payment-reminder, report-generate, audit-write, tenant-purge, cache-rebuild (dto:4-16) |
| Job name | AppTextField | @IsString(), required (dto:30-32) | free text; visible in S1 rows |
| Cron pattern | AppTextField (monospace) | @Matches(/^(\S+\s+){4}\S+$/) — error "Invalid cron pattern (5 fields required)" (dto:20-24) | client mirrors regex on blur; preset chips C8 fill from defaults (scheduler.service.ts:48-119) |
| Timezone | AppDropdown | @IsString(), optional, default UTC (dto:38-41) | only UTC used by defaults (scheduler.service.ts:54,62,70,…); tz aware display in S1 |
| Payload | JSON textarea | optional Record<string, unknown> (dto:34-36) | passed to queue.add(dto.jobName, dto.payload ?? {}, {repeat}) (scheduler.service.ts:200-204) |
Behavior
- Submit →
POST /scheduler(scheduler.controller.ts:30-35) →createJob(scheduler.service.ts:198-208); sheet closes on 201/200; row appears in S1 (optimistic, rollback on failure). - Duplicate warn:
registerRepeatableskips an existingname+pattern(scheduler.service.ts:128-133) — UI shows a confirm before submitting a row that already exists in the S1 list. - Validation errors inline per field; server errors surface in the sheet (envelope per 00-shared/07).
- a11y: each field labelled, error messages in live regions, first invalid field focused on submit.
F2 — Manual Trigger Confirm (S5, (proposed))
Not a form — a confirmation dialog:
- Content: "Trigger
<jobName>→<child job>on queue<queue>now? Deliveries may duplicate — worker idempotency must hold." (child-job map fromjobs/*.job.ts:check-overdue,send-daily-digest,send-payment-reminder,generate-attendance-report). - Confirm disabled while a run is in flight; success → snackbar + S2 run-history refresh.
- No input fields; risk is disclosed, not edited (idempotency is the guard,
bullmq.module.ts:76).
F3 — Remove Schedule Confirm (S1/S2)
- Dialog: "Remove repeatable
<name>(<pattern>,<queue>)?" — matchesremoveRepeatable(jobName, {pattern, tz: 'UTC'})(scheduler.service.ts:194). - Shows pattern+queue read-only so the operator confirms the exact triple (BullMQ dedupes by name+pattern+queue).
- Success → row removed from list (optimistic).
F4 — DLQ Replay / Delete Confirms (S6, (proposed))
- Replay: "Re-queue
<originalJobName>from<originalQueue>? It failed<attemptsMade>× — verify idempotency before retry." (dlq.setup.ts:8-9,12-21). - Delete: standard destructive confirm; note "record will be lost".
Form inventory
| Form | Screen | Endpoint | Status |
|---|---|---|---|
| F1 Create schedule | S3 | POST /scheduler | implemented |
| F2 Trigger confirm | S5 | (proposed) | proposed |
| F3 Remove confirm | S1/S2 | DELETE /scheduler | implemented |
| F4 DLQ replay/delete | S6 | (proposed) | proposed |
09 — User Behaviour (Scheduler Module)
- B1 — Operators scan, they don't search first
- B2 — Status is derived, never guessed
- B3 — Refresh cadence follows the fastest job
- B4 — Reads are cheap; writes are gated
- B5 — Destructive acts require exact confirmation
- B6 — Recovery paths are explicit
- B7 — Offline behaviour
- B8 — Formatting rules
- B9 — Never auto-mutate
- B10 — Analytics (proposed)
Behaviour rules for the operator/admin console — how users read, refresh, act and recover. All console behaviours are
(proposed)except those anchored toscheduler.controller.ts:23-46. Baseline behaviours per 00-shared/04/00-shared/08; conflicts resolve in favour of00-shared.
B1 — Operators scan, they don't search first
The job list is ≤ a few dozen rows (10 defaults scheduler.service.ts:48-119
- custom). Default view = full list grouped by queue, status badges on every row (07 C1). Search only when > 50 rows (not expected).
B2 — Status is derived, never guessed
Today GET /scheduler returns {queue, name, pattern, tz} only
(scheduler.service.ts:152-186) — no status field. Until the (proposed)
run-history endpoint exists, the UI must not fabricate statuses: unknown
rows render the grey unknown badge (C1), and the summary strip shows
"0 known" rather than "all healthy".
B3 — Refresh cadence follows the fastest job
Auto-poll 60 s on S1 (matches audit-flush */1 * * * *,
scheduler.service.ts:78-83 — the highest-frequency default) plus
RefreshIndicator pull. Poll pauses in background and resumes on focus
(shared baseline).
B4 — Reads are cheap; writes are gated
Reads: GET /scheduler only (scheduler.controller.ts:23-28). Writes:
create/remove are superadmin-only (scheduler.read/create/delete,
scheduler.controller.ts:24,31,38) — UI hides actions without permission,
never shows a 403-triggering button.
B5 — Destructive acts require exact confirmation
Remove (F3) and DLQ-delete (F4) always confirm with the identifying triple (name+pattern+queue) or the DLQ record id. No bulk delete, no "apply to all".
B6 — Recovery paths are explicit
Missed run → manual trigger (S5) or DLQ replay (S6). Both warn about
duplication risk (F2/F4) and the UI surfaces the idempotency note
(IdempotencyService, idempotency.service.ts:11-20) so the operator can
decide whether a re-run is safe.
B7 — Offline behaviour
S1/S2/S6 are read-only: last-good payload + AppOfflineBanner; writes
disabled. On reconnect → immediate refresh. Timestamps render "last known".
B8 — Formatting rules
| Value | Rule |
|---|---|
| Cron | monospace as-is (never re-write the 5 fields) |
| Humanized schedule | derived via the C2 map (07 C2) — display only |
| Time | both relative ("2 h ago") and absolute UTC + device-local; never claim a local run time (all jobs run UTC, scheduler.service.ts:48-119) |
| Durations | ms for < 1 s, seconds above; no client math on totals |
| Patterns | 5-field cron, never 6-field (dto regex dto:20-24) |
B9 — Never auto-mutate
No client-side re-enqueue, no optimistic "Run now" without confirm (F2). All state changes go through the API; UI only reflects server truth.
B10 — Analytics (proposed)
{screen}.{action} events per 05 §Analytics; none instrumented today
(00-shared/10 §8).
10 — Interaction Specification (Scheduler Module)
- 1. List → Detail drill-down (S1 → S2)
- 2. Polling loop (S1)
- 3. Create schedule (S3)
- 4. Manual trigger (S5,
(proposed)) - 5. Remove schedule (S1/S2)
- 6. DLQ actions (S6,
(proposed)) - 7. Motion & feedback
- 8. a11y
Interactions of the Scheduled Jobs console, including the polling loop, trigger/remove flows and DLQ actions. Screen refs per 05/06; motion tokens per 00-shared/08.
1. List → Detail drill-down (S1 → S2)
- Row tap →
/admin/scheduler/jobs/:queue/:name(queue+name disambiguate two0 8 * * *jobs:fee-reminder→payment-reminder,admission-reminder-scan→admission-reminder,scheduler.service.ts:92-110). - Detail loads run history
(proposed); header keeps C1/C2/C3 identity. - Back returns to list preserving scroll + filters.
2. Polling loop (S1)
flowchart LR
A[Loaded] --> B[Timer 60s] --> C{cache age < 60s?}
C -->|yes| D[skip request]
C -->|no| E[GET /scheduler]
E --> F[render; update badges]
E -->|error| G[keep last-good + snackbar]
F --> B
- Single-flight: one request in flight; new ticks coalesce.
- Background: timer paused via
WidgetsBindingObserver; on resume → immediate staleness check. - Status enrichments
(proposed)are fetched together with the list, not per row (N+1 avoided).
3. Create schedule (S3)
- FAB "+ New" →
AppBottomSheet(max-width 640 dp); fields per F1. - Preset chips (C8) fill the pattern field; regex re-validates on blur
(
dto:20-24). - Submit →
POST /scheduler(scheduler.controller.ts:30-35); optimistic row insert with rollback on error; duplicate warn per08 §F1.
4. Manual trigger (S5, (proposed))
- Row menu "Run now" → F2 dialog →
(proposed)POST /scheduler/:queue/:name/trigger. - In-flight: confirm button spinner, dialog not re-openable; success →
snackbar + S2 history refresh; the trigger is fire-and-forget server-side
(matches job classes,
jobs/*.job.ts). - Idempotency note displayed in dialog (F2).
5. Remove schedule (S1/S2)
- "Remove" → F3 dialog (name+pattern+queue) →
DELETE /scheduler?queue=&name=&pattern=(scheduler.controller.ts:37-46) →removeRepeatable(jobName, {pattern, tz:'UTC'})(scheduler.service.ts:188-196). Optimistic removal, rollback + snackbar on failure.
6. DLQ actions (S6, (proposed))
- Replay → F4 confirm →
(proposed)POST /scheduler/dlq/:id/retry; must preservecorrelationId/tenantIdfrom the record (dlq.setup.ts:12-21) soIdempotencyServicededup keys stay stable (idempotency.service.ts:12). - Delete → F4 confirm →
(proposed)DELETE /scheduler/dlq/:id. - Status of replay visible in S1/S2 after one poll cycle.
7. Motion & feedback
| Action | Motion (00-shared/08) | Feedback |
|---|---|---|
| Row status change | badge cross-fade m-fast | live region announce |
| Sheet open | m-base slide up | — |
| Trigger/remove | button spinner in place | snackbar (success) / inline error |
| Poll failure | none | keep-last-good + snackbar once per minute |
| Offline | banner slide m-base | AppOfflineBanner |
8. a11y
- Badge: icon + text, never color alone (C1).
- Forms: labels linked, errors in live regions, focus first invalid (F1).
- Cron text:
Semantics(label: "cron pattern, 5 fields")— never spoken as raw five tokens. - Dialog focus trap while open (S3/S5/S6 confirms).
11 — Design System Mapping (Scheduler Module)
- 1. Semantic colour mapping
- 2. Typography
- 3. Spacing & layout
- 4. Iconography
- 5. Responsive & platform behaviour
- 6. a11y & contrast
- 7. Motion
Maps the Scheduled Jobs console to the platform design system (00-shared/02 tokens, 00-shared/03 components, 00-shared/09 a11y). No new tokens; all module components are
(proposed)(07).
1. Semantic colour mapping
| Semantic | Token (00-shared/02) | Used for |
|---|---|---|
| Success | success (green) | JobStatusBadge.ok, replay success |
| Info / running | primary (blue) | JobStatusBadge.running (pulse) |
| Warning | warning (amber) | JobStatusBadge.missed — no run in expected window |
| Error | error (red) | JobStatusBadge.failed, failedReason text, invalid cron |
| Neutral | text secondary | unknown badge, empty states |
| Background surface | surface / surface-variant | cards (C4/C6/C7), log tiles |
Never used: emoji/colour-only status (a11y rule C1, 00-shared/09).
2. Typography
| Use | Token | Notes |
|---|---|---|
| Job names | body-medium, weight 500 | row title |
| Cron patterns | monospace variant | code-styled, never auto-wrapped |
| Run history / logs | monospace body-small | timestamps + error excerpts |
| Humanized schedule | body-small, text-secondary | C2 tooltip + summary |
| Headers/strip counts | headline-small / label-large | C4 summary strip |
3. Spacing & layout
- Console is desktop-first (admin tool, 05 §Keyboard): list max-width 1200 dp; two-column list/detail ≥ 1024 dp.
- Cards: standard
AppCardpadding (16 dp); summary strip flush with list. - Sheets (S3): max-width 640 dp, keyboard-avoiding.
4. Iconography
| Meaning | Icon |
|---|---|
| ok / failed / running / missed / unknown | check-circle / alert-circle / pulse-dot / clock / minus (C1) |
| Remove | delete-outline |
| Run now | play |
| Logs | file-text |
| DLQ | inbox-archive |
| Queue family (C3) | delivery → send; processing → cog; system → server |
5. Responsive & platform behaviour
| Breakpoint | Layout |
|---|---|
| < 640 dp | single column; sheets full-width; cron input smaller but still monospace |
| 640–1023 | list; S2 detail pushed below |
| ≥ 1024 dp | two-column list/detail master-detail |
| Desktop | hover row highlight; menu on hover too |
6. a11y & contrast
- Badges: icon+text, contrast ≥ 4.5:1 for text, ≥ 3:1 for badge fill (00-shared/09).
- Focus order: list → row menu → actions; sheets trap focus (10 §8).
- Timestamps: screen-reader gets absolute UTC; sighted users get relative.
- Logs screen: no auto-scroll while screen reader active (tail-live off unless explicitly enabled, S4).
7. Motion
All motion per 00-shared/08: m-fast 150 ms state changes, m-base
300 ms sheet/route transitions; pulse animation on running badge only
(reduced-motion respect).
12 — API Mapping (Scheduler Module)
- E1 — List repeatable jobs (implemented)
- E2 — Create custom repeatable job (implemented)
- E3 — Remove repeatable job (implemented)
- E4 — Run history
(proposed) - E5 — Job logs
(proposed) - E6 — Manual trigger
(proposed) - E7 — DLQ listing
(proposed) - E8 — DLQ replay / delete
(proposed) - Non-REST surface (the actual work)
- Client contract summary
Honest statement first: the scheduler has no monitoring API surface. Its public surface is three CRUD-ish endpoints on the repeatable-job registry; everything else the console needs (run history, logs, manual trigger, DLQ) is
(proposed)and would be new endpoints. The scheduler's real work happens through cron → BullMQ repeatables → queue internals, not REST. Wire contract per 00-shared/07 (base/api/v1, Bearer JWT, envelopes).
E1 — List repeatable jobs (implemented)
| Endpoint | GET /api/v1/scheduler (scheduler.controller.ts:23-28) |
| Guard | JwtAuthGuard + @Permissions('scheduler.read') (scheduler.controller.ts:18,24) |
| Request | none |
| Response | envelope data: {queue, name, pattern, tz}[] — iterates the 10 queues' getRepeatableJobs() (scheduler.service.ts:152-186), tz defaults 'UTC' (:181) |
| Notes | No status/last-run fields — console status badges are impossible from this endpoint alone (07 C1, 09 B2) |
E2 — Create custom repeatable job (implemented)
| Endpoint | POST /api/v1/scheduler (scheduler.controller.ts:30-35) |
| Guard | scheduler.create (:31) |
| Body | CreateScheduleDto — queue (11 whitelisted, dto:4-16), jobName, 5-field pattern (dto:20-24), optional payload/tz (dto:18-41) |
| Side effect | queue.add(jobName, payload, {repeat: {pattern, tz}, removeOnComplete {1h/100}, removeOnFail {7d}}) (scheduler.service.ts:198-208) |
| Errors | 400 invalid cron/queue; 401; 403; duplicate skipped silently (scheduler.service.ts:128-133) |
E3 — Remove repeatable job (implemented)
| Endpoint | DELETE /api/v1/scheduler?queue=&name=&pattern= (scheduler.controller.ts:37-46) |
| Guard | scheduler.delete (:38) |
| Side effect | removeRepeatable(jobName, {pattern, tz: 'UTC'}) (scheduler.service.ts:188-196); unknown queue → error from getQueue (:210-226) |
E4 — Run history (proposed)
GET /scheduler/runs?queue=&name= — last N runs (startedAt, status,
duration, error). No source today: BullMQ job state holds the data
(attemptsMade, finishedOn, failedReason per dlq.setup.ts:8-21),
retention window bounded by removeOnComplete {age:3600, count:100} and
removeOnFail {age: 7d} (scheduler.service.ts:145-146), failed jobs 14 d
global (bullmq.module.ts:63-64). Backs S2 + S1 badges.
E5 — Job logs (proposed)
GET /scheduler/jobs/:queue/:name/logs?level=&tail= — nestsjs-pino worker
logs (AGENTS.md stack) surfaced by jobId; no log-retrieval endpoint today.
Backs S4.
E6 — Manual trigger (proposed)
POST /scheduler/:queue/:name/trigger — re-enqueues the child job exactly
as the job class would (jobs/*.job.ts pattern: check-overdue,
send-daily-digest, send-payment-reminder, generate-attendance-report).
Must preserve correlationId so IdempotencyService dedup holds
(idempotency.service.ts:11-20). Backs S5.
E7 — DLQ listing (proposed)
GET /scheduler/dlq — records written by setupDlqListener when
attemptsMade >= (opts.attempts ?? 3) (dlq.setup.ts:8-9): originalQueue, originalJobId, originalJobName, data, failedReason, attemptsMade, failedAt
(:12-21). Backs S6.
E8 — DLQ replay / delete (proposed)
POST /scheduler/dlq/:id/retry (re-add to original queue, preserving
correlationId/tenantId), DELETE /scheduler/dlq/:id.
Non-REST surface (the actual work)
| Trigger | Mechanism | Target | Source |
|---|---|---|---|
| 10 default schedules | BullMQ repeatable queue.add(..., {repeat:{pattern,tz:'UTC'}}) | 10 queues | scheduler.service.ts:48-147 |
| Fan-out child jobs | direct queue.add from job classes | see table below | jobs/*.job.ts |
| Event-driven jobs | eventQueueMap route → QueueBridge | emails/push/in-app/audit-write/attendance-process/invoice-generate | event-queue-map.ts:6-43, queue-bridge.service.ts:40-75 |
| Failure path | retry 3× exp. 5 s → DLQ | per queue → dlq | bullmq.module.ts:60-65, dlq.setup.ts:5-27 |
| Repeatable | Cron (UTC) | Child job → queue |
|---|---|---|
overdue-scan | 0 6 * * * | check-overdue → invoice-generate (scheduler.service.ts:49-55, overdue-scan.job.ts:12-19) |
daily-digest | 0 9 * * * | send-daily-digest → emails (:56-62, daily-digest.job.ts:12-25) |
dashboard-rebuild | */5 * * * * | (payload-only) → cache-rebuild (:63-69) |
biometric-sync | */15 * * * * | → biometric-sync (:70-76) |
audit-flush | */1 * * * * | → audit-write (:77-83) |
retention-archive | 0 2 * * 0 | → tenant-purge (:84-90) |
fee-reminder | 0 8 * * * | per-invoice send-payment-reminder → payment-reminder (:91-97, fee-reminder.job.ts:16-47) |
attendance-report-daily | 0 7 * * * | generate-attendance-report → report-generate (:98-104, attendance-report.job.ts:13-28) |
admission-reminder-scan | 0 8 * * * | → admission-reminder (:105-111) |
admission-expiry-scan | 0 2 * * * | → admission-expiry (:112-118) |
Client contract summary
- Reads: E1 today; E4-E8 gated on
(proposed)endpoints — console builds against them as stubs behind a repository interface. - Tenant: scheduler registry is platform-level (
tenantId: 'system'trigger payloads,scheduler.service.ts:139) — console is superadmin surface, not tenant-scoped. - 401 → reauth; 403 → hide module; 429 → backoff poll (api tier, 00-shared/07 §4); 5xx → keep-last-good.
- Rate budget: 1 req/min poll = 60 req/h per console tab — fine.
13 — State Management (Scheduler Module)
- 1. State model (JobsBloc)
- 2. Blocs
- 3. Fetch flow (JobsBloc)
- 4. Cron → queue flow (server, for the console's mental model)
- 5. Manual trigger (S5)
- 6. Cache & staleness
- 7. Error mapping (
00-shared/06 §5)
Bloc-based state for the Scheduled Jobs console per 00-shared/06. Server truth: BullMQ owns job state; the API exposes only the registry (E1-E3 implemented, E4-E8
(proposed)). The console is superadmin-only (platform level,scheduler.controller.ts:24,31,38).
1. State model (JobsBloc)
sealed class JobsState { const JobsState(); }
class JobsInitial extends JobsState {}
class JobsLoading extends JobsState {} // first load
class JobsLoaded extends JobsState {
final List<JobRow> jobs; // {queue,name,pattern,tz} (scheduler.service.ts:152-186)
final Map<String, RunSummary> enrich; // (proposed) lastRunAt,lastStatus,nextRunAt — key queue:name
final DateTime fetchedAt;
}
class JobsError extends JobsState {
final AppError error; // 401/403/429/5xx
final List<JobRow>? lastGood; // keep-last-good
}
JobRow mirrors scheduler.service.ts:152-186 exactly — no client-side
derivation (09 B8/B9). Status badges read only enrich when present,
else unknown (07 C1).
2. Blocs
| Bloc | Owns | Source |
|---|---|---|
JobsBloc | registry list, enrich map, poll loop | E1 + E4 (proposed) |
JobDetailBloc | one job's definition + run history | E1 + E4 |
CreateScheduleBloc | F1 form state, submit, duplicate warn | E2 |
DlqBloc | DLQ records, replay/delete | E7/E8 (proposed) |
LogsBloc | S4 log stream, level filter, tail-live | E5 (proposed) |
Single JobsBloc per /admin/scheduler route, provided in the screen,
disposed on pop (poll timer dies with it).
3. Fetch flow (JobsBloc)
flowchart TD
A[open /admin/scheduler] --> B{lastGood?}
B -->|no| C[emit Loading]
C --> D[GET /scheduler]
D -->|200| E[emit Loaded jobs fetchedAt=now]
D -->|401| F[refresh → replay → fail = session expiry]
D -->|403| G[emit permission-empty]
D -->|429| H[emit Error, skip next poll]
D -->|5xx| I[emit Error keep lastGood]
E --> J[Timer 60s]
J --> K{enrich available? E4}
K -->|yes| L[GET /scheduler/runs?bulk]
K -->|no| M[skip — unknown badges]
L --> E
- Single-flight + coalescing (10 §2);
refresh({force})for pull-to-refresh. - Background: timer paused, resume → immediate staleness check (
09B3).
4. Cron → queue flow (server, for the console's mental model)
flowchart LR
subgraph Boot
M[onModuleInit<br/>scheduler.service.ts:43-45] --> R[registerDefaults<br/>10 jobs :47-126]
R -->|queue.add repeat pattern tz UTC| BQ[(BullMQ repeatables<br/>Redis)]
end
BQ -->|fires on schedule| Q[queue.add child job<br/>jobs/*.job.ts]
Q --> W[Worker<br/>e.g. report.worker.ts]
W -->|tenantContext.run| T[process<br/>tenant-scoped]
T -->|fail| RT{attempts 3<br/>exp 5s :60-65}
RT -->|retry| Q
RT -->|exhausted| DLQ[DLQ<br/>attemptsMade>=attempts<br/>dlq.setup.ts:8-9]
T -->|ok| DONE[done]
5. Manual trigger (S5)
TriggerBloc-free: JobDetailBloc.trigger() → confirm dialog (F2) →
E6 (proposed) → on success refetch run history; button disabled while
in-flight (10 §4). If E6 absent → button disabled with tooltip.
6. Cache & staleness
| Layer | Scope | TTL | Writes on |
|---|---|---|---|
| Server | BullMQ state | retention 1 h / 100 (triggers), 14 d failed (bullmq.module.ts:63-64) | every run |
| Client volatile | registry list sl:scheduler:jobs | 60 s | successful E1 only |
| Client render | — | — | last-good kept; stale banner past 60 s |
7. Error mapping (00-shared/06 §5)
| Error | UI |
|---|---|
| 401 | silent refresh → replay; fail → session expiry |
| 403 | console hidden (superadmin gate, scheduler.controller.ts:24) |
| 429 | skip next poll; snackbar once |
| 404 | job removed concurrently → drop row |
| 5xx | keep lastGood; snackbar ≥ 4 s; no skeleton flash |
14 — QA Checklist (Scheduler Module)
- 1. Registration & idempotency of defaults
- 2. Missed runs & recovery
- 3. Timezone / DST
- 4. Overlap protection
- 5. Failure path & DLQ
- 6. Idempotency
- 7. Tenant isolation
- 8. Security & RBAC
- 9. Console QA
(proposed) - 10. Perf & load
Test matrix for the scheduler backend + the
(proposed)console. Sources:scheduler.service.ts,jobs/*.job.ts,bullmq.module.ts,dlq.setup.ts,idempotency.service.ts,tenant-context.service.ts. Backend items are testable today; console items are(proposed).
1. Registration & idempotency of defaults
-
Fresh boot registers exactly 10 repeatables, one per row of the table
(01 §2;
scheduler.service.ts:48-119). -
Reboot with existing repeatables → no duplicates (
:128-133skip). -
GET /schedulerreturns 10 rows with correct{queue,name,pattern,tz},tzdefaultingUTC(:152-186). - Redis flush mid-run → next boot re-registers all 10 (journey 3, 03).
-
Custom job with identical name+pattern is silently skipped
(
:128-133) — UI mirrors the warn (08 §F1).
2. Missed runs & recovery
- Simulate outage: kill worker > 1 h, restart → audit-flush resumes; backlog bounded by repeatable semantics (missed fire = skipped, not burst).
-
Manual re-trigger path (S5
(proposed)) enqueues exactly one child job. -
Re-trigger after partial success does not double-deliver
(
IdempotencyService,idempotency.service.ts:11-20). -
Operator can confirm "no run in expected window" from S1 badges
(
missed, C1) — depends on E4(proposed).
3. Timezone / DST
-
All 10 defaults registered with
tz: 'UTC'(scheduler.service.ts:54,62,70,76,82,89,96,103,110,117). -
Custom schedules accept explicit tz (
dto:38-41); cron fires at the wall-clock instant in that tz across a DST boundary. - Console renders UTC patterns without pretending to local run times (09 B8).
-
No 6-field cron accepted (5-field regex
dto:20-24).
4. Overlap protection
-
A long-running child job that outlives its next trigger does not
produce concurrent duplicate work (BullMQ repeatable +
attemptsconfigbullmq.module.ts:60-65; verify overlap behaviour is intended fire-and-forget per queue). -
fee-reminderenqueues exactly onesend-payment-reminderper due invoice (ISSUED/PARTIAL, due ≤ 3 days,fee-reminder.job.ts:16-23,25-41). -
attendance-report-dailypayload carriesreportTypecorrectly (attendance-report.job.ts:13-28).
5. Failure path & DLQ
-
Job fails 1×, 2× → retried with exponential 5 s backoff
(
bullmq.module.ts:60-65). -
Fails 3rd time → DLQ record with
originalQueue, originalJobId, originalJobName, data, failedReason, attemptsMade, failedAt(dlq.setup.ts:8-21). -
DLQ replay preserves
correlationId/tenantId(idempotency keys stay stable,idempotency.service.ts:12). -
Failed-trigger retention: 7 d (
scheduler.service.ts:146); global failed retention 14 d (bullmq.module.ts:63-64) — DLQ viewer shows what actually exists (S6 empty states honest, 09 B2).
6. Idempotency
-
sl:idempotency:{jobId}key set via SET NX PX 300 s (idempotency.service.ts:11-20); second delivery within TTL is dropped. - Expired key (job > 5 min) re-processes — accepted trade-off, documented.
-
QueueBridge event path dedupes on
{correlationId}:{eventType}(queue-bridge.service.ts:44-46).
7. Tenant isolation
-
Workers restore context via
tenantContext.runbefore touching repositories (report.worker.ts:18-30; also finance/attendance/admission/ inapp workers, 01 §7). -
Gap:
FeeReminderJobqueriesInvoiceRepositorydirectly without wrapping intenantContext.run(fee-reminder.job.ts:20-23) while the trigger payload carriestenantId: 'system'(scheduler.service.ts:139) — verify query scoping and either wrap the job or confirm intended cross-tenant scan. Open QA risk. -
Trigger payloads never read
tenantIdfrom operator input; custom create DTO has no tenant field (create-schedule.dto.ts:18-41).
8. Security & RBAC
-
scheduler.read/create/deleteenforced on E1/E2/E3 (scheduler.controller.ts:24,31,38). -
Custom-job payload cannot inject arbitrary queue names (whitelist
dto:4-16). - Console hides actions without permission (09 B4).
9. Console QA (proposed)
- S1 poll loop: 60 s cadence, single-flight, background pause (10 §2, 13 §3).
- S2 run history matches retention reality (1 h/100, 7 d, 14 d — S2 note).
-
S3 duplicate warn; invalid cron inline error matches server message
"Invalid cron pattern (5 fields required)" (
dto:22). - S6 replay button disabled until E8 exists.
- a11y: badges icon+text; live regions; focus traps (11 §6).
10. Perf & load
-
listJobsacross 10 queues stays < 1 s at 100+ repeatables each (scheduler.service.ts:152-186). - Poll of E1 + enrich does not exceed api tier 100 req/min (00-shared/07 §4).
-
DLQ listener attaches once per worker — no duplicate records on
restart (
dlq-listener.service.ts:31-44).
15 — Flutter Implementation Guide (Scheduler Module)
- 1. Module skeleton
- 2. Data layer
- 3. Blocs
- 4. Key widgets
- 5. Polling & background
- 6. Console behaviour gates
- 7. Tests
- 8. Delivery checklist
Build guide for the Scheduled Jobs console (
(proposed)screens S1-S6). Native mobile is excluded from PRD Phase 1 (web-first,PRODUCT_REQUIREMENTS_DOCUMENT.md:144) — this guide targets the Flutter web-first responsive client per 00-shared/11; the scheduler console is a superadmin platform surface (scheduler.controller.ts:24,31,38). Only E1-E3 exist server-side; E4-E8(proposed)— stub behind a repository interface and ship read-only until they land.
1. Module skeleton
features/scheduler/
├── data/
│ ├── scheduler_api.dart // E1-E3 + stubbed E4-E8 (proposed)
│ └── scheduler_repository.dart // console talks to this only
├── domain/
│ └── job_row.dart // {queue,name,pattern,tz} (scheduler.service.ts:152-186)
├── blocs/
│ ├── jobs_bloc.dart // list + poll + enrich (13 §1-3)
│ ├── job_detail_bloc.dart // S2
│ ├── create_schedule_bloc.dart // S3/F1
│ ├── dlq_bloc.dart // S6
│ └── logs_bloc.dart // S4
├── screens/ s1_jobs_list.dart · s2_job_detail.dart · s3_create_sheet.dart
│ s4_job_logs.dart · s6_dlq.dart
└── widgets/ job_status_badge.dart · cron_chip.dart · queue_chip.dart
schedule_summary_strip.dart · run_history_row.dart
job_definition_card.dart · dlq_card.dart · preset_cron_chips.dart
Routes: /admin/scheduler, /admin/scheduler/jobs/:queue/:name,
/admin/scheduler/jobs/:queue/:name/logs, /admin/scheduler/dlq (04 §5).
2. Data layer
class SchedulerApi {
Future<List<JobRow>> listJobs() // E1: GET /scheduler
Future<void> createJob(CreateScheduleDto dto) // E2: POST /scheduler
Future<void> removeJob(q, name, pattern) // E3: DELETE /scheduler
// (proposed — throw UnimplementedError until shipped):
// runs(q,name) E4 · logs(q,name) E5 · trigger(q,name) E6 · dlq() E7 · dlqRetry(id) E8
}
- Auth: Bearer JWT via shared client; 401 → refresh → replay (
00-shared/06 §3.6). - Error mapping per
13 §7(401 reauth, 403 hide, 429 backoff, 404 drop row, 5xx keep-last-good). JobRow.fromJsonmirrorsscheduler.service.ts:152-186only — no status fields exist yet (09 B2);enrichmap filled from E4 when it ships.
3. Blocs
JobsBloc:load(),refresh({force}),Timer.periodic(60 s)started on Loaded, cancelled inclose(); single-flight + coalescing; background pause viaWidgetsBindingObserver(13 §3).JobDetailBloc: header from E1 row + run history E4 (stub → empty state with retention note "triggers keep 1 h/100 completed, failed 7 d",scheduler.service.ts:145-146).CreateScheduleBloc: F1 fields; cron regex client-side^(\S+\s+){4}\S+$mirroringdto:20-24; duplicate warn by comparing against currentJobsBlocrows (08 §F1).
4. Key widgets
| Widget | Impl notes |
|---|---|
JobStatusBadge | icon+text always; unknown grey until E4 (07 C1) |
CronChip | monospace pattern + humanized via the C2 map; invalid state for F1 input |
QueueChip | family colour coding from queue.constants.ts:1-17 (07 C3) |
DLQCard | fields from dlq.setup.ts:12-21; expandable JSON data |
All stateless + controlled (07 reuse rules); tokens from 00-shared/02.
5. Polling & background
- 60 s poll on S1 only (09 B3); S2/S6 refresh on entry + pull.
AppLifecycleListener: pause timer on background, immediate staleness check on resume.- Rate budget: 1 req/min ≈ 60/h per tab — inside api tier (
00-shared/07 §4).
6. Console behaviour gates
- Without E4: S1 badges show
unknown, S2 shows retention note, strip shows "0 known" (09 B2) — never fake health. - Without E6: "Run now" disabled with tooltip "manual trigger pending" (13 §5).
- Without E8: DLQ replay button disabled (14 §9).
- Create/remove work today (E2/E3) — those are live, test them fully.
7. Tests
- Widget: badge states (ok/running/failed/missed/unknown), cron chip
humanizer for all 10 defaults (
scheduler.service.ts:48-119), F1 regex (5-field cron in/out). - Bloc: poll timer lifecycle, keep-last-good on 5xx, single-flight coalescing (13 §3).
- Integration (console,
(proposed)endpoints): repository stub contract — swap fake for real when E4-E8 ship.
8. Delivery checklist
- Reads E1; writes E2/E3 wired through repository
-
(proposed)endpoints stubbed, UI degrades honestly (07 C1 unknown) - a11y: badge icon+text, live regions, focus traps (11 §6)
-
Analytics
scheduler.{screen}.{action}(proposed)(05 §Analytics) -
npm run typecheck && npm run lintbefore any backend pairing change
01 — Product Overview (Search Module)
- 1. What the module is
- 2. Entities searched (from
search-indexer.service.ts:7-32) - 3. What is matched (indexed fields)
- 4. Scope in / scope out
- 5. PRD native-app exclusion (flagged)
- 6. Terminology
Global search across all indexed StudyLyon entities. Derived from
src/modules/search/**(controller, service, indexer, schema, repository, DTO),src/modules/rbac/permissions.constants.ts,src/infrastructure/redis/,docs/IMPLEMENTATION_PLAN.md, andsrc/common/dto/pagination-query.dto.ts. Nothing is invented; plan-only capability is marked(planned), client-only or roadmap-only capability(forward-looking), analytics(proposed).
1. What the module is
A single global search endpoint that searches a dedicated search_indexes
collection (a denormalized mirror of selected business entities) and returns an
aggregated flat list of typed results, paginated and relevance-ranked.
Key facts from source:
- One endpoint:
GET /api/v1/search(@Controller('search')search.controller.ts:11;@Get():15).@ApiTags('search'), bearer auth,JwtAuthGuard(search.controller.ts:8-10). - Guarded by the single
@Permissions('search')permission (search.controller.ts:16; declaredpermissions.constants.ts:93— note: it is a bare'search'string, not asearch.*wildcard family). - Result shape is a flat
SearchResultlist —{ entityType, entityId, title, description, tags }(search.service.ts:10-16). Typed grouping is a client concern; the API is not grouped. - Query params:
q(string, optional),entityType(string, optional),page(≥1, default 1),limit(1–50, default 20) (search-query.dto.ts:5-30). - Empty/whitespace
qreturns{ data: [], meta }withtotalItems: 0(search.service.ts:28-30) — no error, no index scan. - Index maintained event-driven:
SearchIndexerServicesubscribes to every domain event viaeventBus.onAny(search-indexer.service.ts:44-46) and upserts/soft-deletes index rows (search-indexer.service.ts:48-99). - MongoDB full-text (
$text+textScore) with a regex fallback (search-index.repository.ts:20-50); relevance sort by textScore.
2. Entities searched (from search-indexer.service.ts:7-32)
| entityType | Source events | Notes |
|---|---|---|
User | UserCreated, UserUpdated, UserDeleted | index title = name; description = email |
Student | StudentCreated/Updated/Deleted | title falls back to admissionNumber (search-indexer.service.ts:71) |
Teacher | TeacherCreated/Updated/Deleted | tags may include role/department/grade (search-indexer.service.ts:83-86) |
Staff | StaffCreated/Updated/Deleted | same extraction pipeline |
Parent | ParentCreated/Updated/Deleted | same |
Book | BookCreated/Updated/Deleted | library catalogue; description = book description |
Organization | OrganizationCreated/Updated/Deleted | tenant-level entity |
Lead | LeadCreated only (:29) | gap: no LeadUpdated/LeadDeleted mapping → stale/dead lead rows persist |
Announcement | AnnouncementCreated, AnnouncementPublished (:30-31) | gap: no updated/deleted mapping |
Plan-only additions ((planned)): index batches, sessions, test_series, DPP
for the coaching module (docs/IMPLEMENTATION_PLAN.md:771); the plan's
"Global Search" task targets "students, staff, leads, books, announcements"
(docs/IMPLEMENTATION_PLAN.md:174).
3. What is matched (indexed fields)
Per index row: title, description, tags, and a denormalized text
string "${title} ${description} ${tags.join(' ')}"
(search-indexer.service.ts:90-96). The Mongo text index covers
title, description, tags, text (search-index.schema.ts:29-34);
the regex fallback matches the same four fields, case-insensitive substring
(search-index.repository.ts:36-50). Match fields map from source payloads:
title = name → title → admissionNumber → firstName + lastName
(search-indexer.service.ts:68-75); description = email → description →
body (:77-81); tags = role, department, grade (:83-86).
4. Scope in / scope out
| In scope (implemented) | Out of scope / noted |
|---|---|
| Global query across indexed entities, relevance-ranked | Per-entity native search endpoints — module owns a single GET /search |
entityType filter + standard pagination | Result grouping / typed sections — client-side concern |
| Event-driven index sync (create/update/delete for 7 entity types) | External engine (Elasticsearch/Meilisearch) (planned) — none in code |
Tenant isolation on every read/write (base.schema.ts:9-31) | Search result caching — RedisCacheService exists (redis-cache.service.ts:16-19) but is not wired into SearchService (proposed) |
Regex fallback when $text errors | Search history / saved searches / suggestions (forward-looking) |
| Lead/Announcement update-delete sync — known gap (see §2) |
5. PRD native-app exclusion (flagged)
Per the shared ledger A1 (00-shared/12): the PRD puts native mobile apps
out of Phase 1 (PRODUCT_REQUIREMENTS_DOCUMENT.md:144), roadmap Phase 3 =
read-only companion. This package is the forward-looking client spec for
the Flutter app against the v1 API; the backend (controller/service/repo) is
fully implemented and is the only Phase-1 surface. QR-code entry (scan a
book/student barcode into the search bar) is (forward-looking) — no QR
backend support (ledger B4).
6. Terminology
- Index row — one
search_indexesdocument per(tenantId, entityType, entityId)(search-index.schema.ts:7-25). - Scoring — MongoDB
textScoreused for sort only; not returned to the client (search-index.repository.ts:28-33). - Fallback — case-insensitive regex
$orscan used when$textthrows (search.service.ts:35-41).
02 — User Personas (Search Module)
- 1. Admin / Principal
- 2. Receptionist / Front-office clerk
- 3. Teacher
- 4. Librarian
- 5. Parent (mobile companion)
(forward-looking) - 6. Operations lead (coaching module)
(planned) - 7. Anti-personas (explicitly out)
- Persona × entity matrix (what each persona mostly finds)
Who uses global search, what they look for, and how the index serves them. Personas are grounded in the entities the indexer actually indexes (
search-indexer.service.ts:7-32) and the singlesearchpermission (permissions.constants.ts:93). All personas are tenant-internal — the repository scopes every query to the JWT tenant (search-index.repository.ts:24-26).
1. Admin / Principal
- Access:
searchpermission +JwtAuthGuard(search.controller.ts:10,16). - Goal: find any person, document, or record fast from the dashboard search bar.
- Typical queries: staff member by name, student by admission number, book by title.
- Served by: index rows for
User,Student,Teacher,Staff,Parent,Book,Organization,Announcement(search-indexer.service.ts:7-32). - Expectation: results in < 1 s; taps a result and lands on the entity detail.
- Frustration risk: stale results for leads/announcements — no
update/delete events are mapped for those types (
search-indexer.service.ts:29-31).
2. Receptionist / Front-office clerk
- Access:
searchpermission. - Goal: locate a student or parent quickly when they arrive.
- Typical queries: student name fragments, admission number
(
search-indexer.service.ts:71), parent email (:78). - Served by: substring-tolerant fallback (
$regexcase-insensitive on title/description/text/tags —search-index.repository.ts:36-50); Mongo$textneeds word-ish tokens, so partial names rely on the fallback path. - Expectation: type-ahead with debounce; "see all" drill-down per entity type when many matches.
3. Teacher
- Access:
searchpermission. - Goal: find students in their class, locate colleagues, find announcements.
- Typical queries: student name, announcement keyword (matched on
body→ description —search-indexer.service.ts:80), book title for the library. - Served by:
Student,User,Announcement,Bookrows. - Expectation: announcement search should find the latest copy; known gap:
only
AnnouncementCreated/Publishedare indexed, edits do not re-sync.
4. Librarian
- Access:
searchpermission. - Goal: check whether a title exists in the catalogue without opening the library module.
- Typical queries: partial title, author in description.
- Served by:
BookCreated/Updated/Deleted→Bookrows (search-indexer.service.ts:13,20,27). - Expectation: ISBN-style tokens; numeric tokens work through
text/tagsfields since text search includes them.
5. Parent (mobile companion) (forward-looking)
- Access: future read-only companion role — PRD Phase 3 (00-shared/12 A1).
- Goal: find their child, school announcements, staff contacts.
- Served by: the same endpoint (permission permitting) — no parent-specific search surface exists in code.
- Expectation: results are tenant-scoped; parent must never see cross-tenant
rows (
base.schema.ts:9-31, repository scoped filter).
6. Operations lead (coaching module) (planned)
- Goal: search batches, sessions, test series, DPP content.
- Served by: plan-only index extension
(
docs/IMPLEMENTATION_PLAN.md:771) — not in code today.
7. Anti-personas (explicitly out)
- Cross-tenant operator: impossible by construction — every read/write is
tenant-scoped (
search-index.repository.ts:84-89;redis-cache.service.ts:16-19even namespaces cache keys per tenant, though search does not use Redis yet). - Unauthenticated user: 401 —
JwtAuthGuardon the controller (search.controller.ts:3,10).
Persona × entity matrix (what each persona mostly finds)
| Persona | User | Student | Teacher | Staff | Parent | Book | Organization | Lead | Announcement |
|---|---|---|---|---|---|---|---|---|---|
| Admin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Receptionist | ✓ | ✓ | — | ✓ | ✓ | — | — | ✓ | — |
| Teacher | ✓ | ✓ | ✓ | — | — | ✓ | — | — | ✓ |
| Librarian | — | — | — | — | — | ✓ | — | — | — |
Parent (forward-looking) | — | ✓ | — | — | — | — | — | — | ✓ |
Gaps this matrix exposes are the same event-mapping gaps: leads are
create-only (search-indexer.service.ts:29), announcements never update
(:30-31).
03 — User Journey (Search Module)
- J1. Find a student by admission number (receptionist)
- J2. Locate a staff member by email
- J3. Search a book in the catalogue (librarian)
- J4. Drill into a type ("see all")
- J5. Zero results
- J6. Search during degraded backend (fallback path)
- J7. Re-search after entity change
- J8. (Forward-looking) QR scan → search
- J9. (Forward-looking) Push deep link → search
End-to-end journeys for the global search surface. Every backend behavior cited is implemented; screen-level behaviors are the forward-looking client spec. States per 00-shared/10 §1 conventions.
J1. Find a student by admission number (receptionist)
- Receptionist opens the app → global search bar visible in the app shell (entry point per 05_Screen_Inventory.md §1).
- Types
ADM-20— client debounces ~300 ms(proposed). GET /api/v1/search?q=ADM-20&page=1&limit=20fires (search.controller.ts:15-18); server matchestext/titlecontaining the token — admission numbers land intitleviaadmissionNumberfallback (search-indexer.service.ts:71).Studentgroup appears first with matching rows (entityTypeon each result —search.service.ts:51-57); receptionist taps the row.- Navigates to the student detail screen (module-owned route).
Backend guarantees: tenant-scoped results (search-index.repository.ts:24-26);
relevance order by textScore (:28-33).
J2. Locate a staff member by email
- User types a partial email, e.g.
sharma@. - Regex fallback path may be hit — emails are indexed as
description(search-indexer.service.ts:77-79);$textand fallback both coverdescription. Staffrows show; result tile renders title (name) + description (email).
J3. Search a book in the catalogue (librarian)
- Types a partial title.
$textmatches word stems; short fragments fall back to the case-insensitive substring scan. Bookgroup shown with title + description; tap → library book detail.
J4. Drill into a type ("see all")
- User runs
q=qand sees 40 mixed results (2 pages of 20 —limitmax 50, default 20,search-query.dto.ts:23-29). - Taps "See all Students" → same endpoint with
entityType=Student(search-query.dto.ts:11-14) — server filters in memory after the search (search.service.ts:44-49); note: the filter is applied to the current page only, sototalItemsreflects the filtered page length, not the true per-type total (search.service.ts:46-48) — flagged in QA-14. - Paging continues through the type-filtered list.
J5. Zero results
- Types gibberish or a query with no indexed match →
data: [],meta.totalItems: 0(search.service.ts:28-30is the empty-query path; no-match path returns empty viarepo.search). - Client shows
AppEmptyState("No results for “x”") with a clear-query CTA (shared 00-shared/03).
J6. Search during degraded backend (fallback path)
- Mongo
$textthrows (e.g. malformed index state) →SearchServicecatches and re-runs the regex fallback transparently (search.service.ts:35-41). - User sees identical UI, possibly different result order (fallback has no
relevance sort —
search-index.repository.ts:49).
J7. Re-search after entity change
- A student is renamed →
StudentUpdatedevent → indexer upserts the row (search-indexer.service.ts:48-96) → next search reflects the new title. - Known gap: renaming a lead or editing an announcement never fires an
indexed event (
search-indexer.service.ts:29-31) → stale rows until a future reindex job(planned).
J8. (Forward-looking) QR scan → search
- User taps the scan icon in the search bar; scans a printed admission number / book barcode.
- Camera value fills
q→ normal search flow. No QR backend endpoints exist (ledger B4) — fully client-side(forward-looking).
J9. (Forward-looking) Push deep link → search
- A notification arrives for a new announcement
(forward-looking)— push device registry not implemented (ledger B3). - Tap → app opens with
?q=<title>prefilled → standard results screen.
04 — Information Architecture (Search Module)
- 1. Entry point: the global search bar
- 2. What the index contains (searchable universe)
- 3. Result organization
- 4. Deep links
- 5. What is NOT in this IA
- 6. Navigation summary
Where global search lives in the app, what it searches, and how results are organized. Backend facts from
src/modules/search/**; app-shell conventions from 00-shared/05_Global_Information_Architecture.md.
1. Entry point: the global search bar
Search is a cross-module utility, not a tab. It lives as a persistent
search bar in the app shell (top app bar), reachable from every authenticated
screen — mirroring the backend's single cross-cutting endpoint (GET /search,
search.controller.ts:11-18). Route: /search (results surface); the input
itself is an in-shell control that routes to /search on commit.
2. What the index contains (searchable universe)
One denormalized search_indexes collection per tenant
(search-index.schema.ts:7); rows are created from domain events
(search-indexer.service.ts:7-32):
- 7 fully-synced types:
User,Student,Teacher,Staff,Parent,Book,Organization(create/update/delete). - 2 partially-synced types:
Lead(create only),Announcement(create/publish only) — no update/delete events (search-indexer.service.ts:29-31). (planned): batches, sessions, test_series, DPP (docs/IMPLEMENTATION_PLAN.md:771).
3. Result organization
The API returns a flat, relevance-ranked list — there is no server-side
grouping (search.service.ts:10-16, :51-57). The client groups by
entityType with headers (see 06 §4). Filtering by type = the optional
entityType query param (search-query.dto.ts:11-14).
/search
├── Group: Students (entityType=Student)
│ └── SearchResultTile × n (title, description, tags)
├── Group: Books
│ └── ...
└── Group: Announcements
└── ...
Group order is client-proposed (people first, then content) — the server does
not order groups; ordering by textScore happens inside each flat list
(search-index.repository.ts:28-33).
4. Deep links
| Route | Purpose | Backing |
|---|---|---|
/search | results surface for current query | GET /search?q=… |
/search?type=Student | see-all drill-down | entityType param (search-query.dto.ts:11-14) |
/search/result/<entityType>/<entityId> (proposed) | deep-link to a specific result | key = (entityType, entityId) (search.service.ts:11-12) |
Result rows deep-link into the owning module's detail route (Students →
/students/:id, etc.) — no dedicated result-detail screen exists; the
module only returns pointers (entityId, entityType).
5. What is NOT in this IA
- No search history / recent searches / saved searches / suggestions —
(forward-looking), nothing server-side. - No per-entity search endpoints — other modules' lists use their own
qfilters (pagination-query.dto.ts:26-29); this module is the global aggregator only. - No cross-tenant traversal — every row and query carries
tenantId(base.schema.ts:9-31;search-index.repository.ts:84-89). - No results cache in the data path — Redis is tenant-namespaced and
available (
redis-cache.service.ts:16-19) but unused bySearchService(proposed).
6. Navigation summary
| From | To | Trigger |
|---|---|---|
| Any shell screen | /search | tap search bar / type |
/search | /students/:id, /books/:id, … | tap result row (owner module route) |
/search | /search?type=X | "See all X" chip |
/search?type=X | /search | clear type filter |
05 — Screen Inventory (Search Module)
- Legend
- 1. Global Search Bar (in-shell control, every authenticated screen)
- 2. Search Results Screen (
/search) — the module's only full screen - 3. See-All Drill-Down (
/search?type=<entityType>) - 4. Zero-State / Landing (
/searchwith empty query) - 5. Recent Searches panel
(forward-looking) - 6. Scan-to-Search sheet
(forward-looking) - Shared components used
- Analytics events (proposed)
- Keyboard, landscape, tablet, desktop
Every screen of the Search module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics. Backend source:
src/modules/search/**.
Legend
States = idle / loading / success / empty / error(offline, server, invalid) / disabled / permission.
Analytics events follow {module}.{screen}.{action} (proposed; SDK open — 00-shared/10 §8).
1. Global Search Bar (in-shell control, every authenticated screen)
| Field | Detail |
|---|---|
| Purpose | Capture the query that drives the single search endpoint |
| Entry | present on all shell screens; focused state → /search |
| Exit | submit → /search?q=…; cancel → back to previous screen |
| Input | AppTextField-based SearchBarField (module component, 07 §1): hint "Search students, books, announcements…", clear button, optional scan icon (forward-looking) |
| Behavior | debounced 300 ms (proposed); results update in place while typing; submit commits exact query |
| Permission | search (permissions.constants.ts:93); without it the bar is hidden |
| States | idle, typing (IME composing), loading (inline spinner, no layout jump), disabled (permission), offline (banner) |
| Keyboard | TextInputType.text, textInputAction.search, autocorrect off (names/IDs) |
| a11y | semantics hint "Search the whole school"; clear button labeled; live-region on result count |
| Analytics | search.bar.focus, search.bar.submit, search.bar.clear (proposed) |
| Motion | focus underline m-fast; results fade/slide m-base (00-shared/08) |
2. Search Results Screen (/search) — the module's only full screen
| Field | Detail |
|---|---|
| Purpose | Show relevance-ranked results grouped by entity type; jump to any entity |
| Entry | from search bar submit; deep link ?q= (forward-looking) |
| Exit | tap result → owner module detail route; back → shell |
| Source | GET /api/v1/search?q&entityType&page&limit — flat SearchResult[] + PaginationMeta (search.service.ts:10-16,59) |
| Groups | client-side grouping by entityType (search.service.ts:11); group headers with counts (count = rows on page, not total — see QA-14) |
| Loading | AppSkeleton list; keep previous results + dim while typing new query (avoid flicker) |
| Empty | AppEmptyState "No results for “q”" + clear CTA (server returns data: [] — search.service.ts:28-30) |
| Error | AppErrorState with retry; note: server auto-falls back to regex internally (search.service.ts:35-41), so client errors are rare |
| Pagination | infinite scroll (page+1 on scroll end); meta.hasNext gate (pagination-query.dto.ts:41-55) |
| Pull-to-refresh | RefreshIndicator re-runs current query (fresh index read) |
| a11y | group headers as headings; per-row semantics label "{title}, {entityType}, {description}" |
| Motion | group insert/remove m-base; row tap ripple |
| Analytics | search.results.shown(count,type), search.result.tap(entityType), search.results.empty, search.seeall.tap(type) (proposed) |
3. See-All Drill-Down (/search?type=<entityType>)
| Field | Detail |
|---|---|
| Purpose | Browse all matches of one entity type with pagination |
| Source | same endpoint, entityType set (search-query.dto.ts:11-14) |
| Grouping | no groups — flat list of one type; app bar shows type label + result count |
| Empty | "No {type} found for “q”" |
| Caveat | server filters the fetched page only (search.service.ts:44-49) — drill-down paging stays correct per page, but cross-page type totals are approximations; QA-14 |
| Exit | back → /search (query preserved in cubit state) |
4. Zero-State / Landing (/search with empty query)
| Field | Detail |
|---|---|
| Purpose | Explain the surface before any query |
| Source | none — client-only; server returns data: [] for blank q (search.service.ts:28-30) |
| Content | icon, "Search across your school", example chips ("Student name", "Admission no.", "Book title", "Announcement") — tap chip fills bar |
| States | only idle + offline |
| Analytics | search.landing.chip.tap(topic) (proposed) |
5. Recent Searches panel (forward-looking)
| Field | Detail |
|---|---|
| Purpose | Quick re-run of past queries (client-side persistence only — ledger B5) |
| Source | none — device storage; no backend history endpoint |
| Note | keep dormant; single-tap re-run; clear-all action |
6. Scan-to-Search sheet (forward-looking)
QR/barcode capture filling q — no backend QR support (ledger B4); see 03 J8.
Shared components used
AppTextField, AppButton, AppChips, AppAvatar, AppListTile,
AppSnackbar, AppSkeleton, AppEmptyState, AppErrorState,
AppOfflineBanner, AppSpinner, AppTag. Module-specific: SearchBarField,
SearchResultTile, SearchGroupHeader, SearchResultSkeleton — defined in
07_Component_Library.md.
Analytics events (proposed)
search.bar.{focus,submit,clear}, search.landing.chip.tap,
search.results.{shown,tap,empty,seeall}, search.error.{server,offline}
(all proposed).
Keyboard, landscape, tablet, desktop
- Portrait: bar top, results scroll below.
- Tablet/desktop: results in two-column grid groups; keyboard shortcuts
/focuses search (desktop), Esc clears. - Keyboard avoidance +
resizeToAvoidBottomInset(shared baseline).
06 — Screen Specifications (Search Module)
- 1. Global Search Bar (in-shell)
- 2. Results Screen State Machine
- 3. Query contract (what the client sends)
- 4. Empty-query behavior (exact)
- 5. Stale-data posture
- 6. Keyboard / platform adaptations
The authoritative behavioral spec for the Search results surface. Every backend behavior below is verified against source with file:line references. Client behaviors are the forward-looking spec (PRD keeps mobile out of Phase 1 — 00-shared/12 A1). This is the largest file in the package by design: it pins the full state machine of the only full screen plus the in-shell bar.
1. Global Search Bar (in-shell)
1.1 Composition
[AppBar leading] SearchBarField [scan icon*] [clear ✕]
(hint, debounce, submit)
* forward-looking
- Height 48 dp; rounded 24 dp;
AppTextFieldvariant with search icon (shared00-shared/03). - Clear button appears only when text is non-empty; clears and re-shows
landing (zero-state,
05 §4).
1.2 Behavior contract
| Event | Client action | Server effect |
|---|---|---|
| Text change (composing, non-empty) | restart 300 ms debounce timer (proposed); fire request on expiry | GET /search?q=… — trimmed query; blank/whitespace q short-circuits server-side to empty result (search.service.ts:28-30) |
| Text change (empty) | cancel timer; emit idle; show landing | no request |
| Submit (enter / search action) | commit query immediately, cancel debounce | same endpoint; page resets to 1 |
| Tap "See all X" | set entityType param, page 1 | server filters fetched page by type (search.service.ts:44-49) |
| Tap result | navigate to owner module detail (/students/:id etc.) | none — result carries entityType + entityId (search.service.ts:11-12) |
1.3 Debounce specification
- Delay: 300 ms
(proposed)— tuned for a Mongo$textquery withskip/limit(search-index.repository.ts:27-33). - Rule: only the latest query fires; any earlier in-flight response for a
superseded query is discarded (stale-guard token,
13§3). - Exceptions: no debounce on submit; no debounce on pagination (fires immediately).
- Why: each keystroke would otherwise hit
$text+countDocuments+ optional fallback — two queries per search (search.service.ts:36-37).
1.4 Permission behavior
- Bar hidden entirely without
searchpermission (permissions.constants.ts:93). - If the token expires mid-typing: 401 → global session-expiry flow
(
00-shared/10 §3), state preserved, re-login resumes.
2. Results Screen State Machine
States (legend per 05): idle → loading → success | error | empty; the
machine is implemented as SearchCubit (13).
2.1 idle
- No query. Landing content only (
05 §4). No network.
2.2 loading
- Triggered: debounce expiry, submit, pagination fetch, retry.
- UI: previous results stay visible for refinement (reload, not
replace); first-ever query shows
SearchResultSkeleton(07§4). - Inline spinner in bar; list dimmed at 40% opacity.
- Minimum spinner display 250 ms to avoid flash on fast responses
(proposed).
2.3 success
data: SearchResult[](search.service.ts:10-16), rendered grouped.- Grouping: client groups by
entityType; server sends flat list (search.service.ts:51-57). Group order (proposed): Student, User, Teacher, Staff, Parent, Book, Organization, Lead, Announcement — stable, types absent from results are omitted. Header shows label + per-page count. - Row anatomy (
SearchResultTile):- Leading:
AppAvatar(initials fromtitle; entity-type icon fallback). - Title:
title(e.g. full name, admission number, book title — extraction rulessearch-indexer.service.ts:68-75). - Subtitle:
description(email, book description, announcement body —:77-81), ellipsized 2 lines. - Trailing:
AppTags fortags(role/department/grade —:83-86), max 2 + "+n". - A11y label:
"{title}, {entityType}, {description}".
- Leading:
- Empty description/tags: omit rows' subtitle/trailing — defaults are
''and[](search-index.schema.ts:18-22). - Pagination: scroll to 80% of the list → fetch
page+1(limit 20 default, max 50 —search-query.dto.ts:23-29) ifmeta.hasNext(pagination-query.dto.ts:41-55). Append; show bottom loading row. - Counts: header count is per fetched page, not a global total —
server computes
totalItemsfromcountSearch(q)(search.service.ts:37), but per-type totals are unknowable from the flat response (search.service.ts:44-49).
2.4 empty
- Server:
data: []withmeta.totalItems: 0. Two sources:- blank/whitespace
q—search.service.ts:28-30(client never sends it, by contract); - no matches in index (text index or fallback scan) —
search-index.repository.ts:20-50.
- blank/whitespace
- UI:
AppEmptyState: "No results for “q”", sub-line "Check the spelling or try fewer words." CTA: "Clear search" (returns to landing). - Do not show suggestions (no backend suggestions
(forward-looking)).
2.5 error
- Server auto-fails-over:
$textfailure → regex fallback (search.service.ts:35-41) — so most query-level failures never reach the client. Clienterrortriggers on: 401 (session), 403 (permission revoked mid-session), 429 (rate limited —00-shared/07 §4), network/offline. - UI:
AppErrorStatewith message + Retry (re-run same query) and "Back". - Offline:
AppOfflineBanner+ cached-last-results if any; retry on connectivity restore (00-shared/10 §2).
2.6 permission
- Not reached from the bar (hidden), but direct deep links
(forward-looking)must render a permission state: icon + "You don't have search access." (403→PERMISSION_DENIED,00-shared/07 §3).
3. Query contract (what the client sends)
| Param | Source | Rules |
|---|---|---|
q | search-query.dto.ts:6-9 | optional string; client trims; never send blank; recommended client cap 100 chars (proposed) — server has no max |
entityType | search-query.dto.ts:11-14 | optional string; client sends one of the 9 known types |
page | search-query.dto.ts:16-21 | ≥1, default 1 |
limit | search-query.dto.ts:23-29 | 1–50, default 20; validation errors → 400 VALIDATION_ERROR (00-shared/07 §3) |
4. Empty-query behavior (exact)
Server: !q?.trim() → { data: [], meta: buildPaginationMeta(page, limit, 0) }
(search.service.ts:28-30) — totalPages computes to 1, hasNext/hasPrevious
false (pagination-query.dto.ts:46-54). No index access. Client contract:
never send it; landing screen (05 §4) covers the idle state.
5. Stale-data posture
- Index freshness = event-driven only; no reindex job in code
(
search-indexer.service.ts:44-46). - Known staleness:
LeadandAnnouncementrows never update/delete (search-indexer.service.ts:29-31) → renamed/edited leads and edited announcements surface stale titles until(planned)reindex/event wiring. - UI: no staleness signal today; when a reindex job ships
(planned), surfaceupdatedAton tiles (base.schema.ts:31-33).
6. Keyboard / platform adaptations
- Tablet/desktop: two-column group layout;
/focuses bar;Escclears. - IME composing ignored until composition end (CJK-safe debounce).
- Landscape: same as portrait, more tiles per row.
07 — Component Library (Search Module)
- 1.
SearchBarField - 2.
SearchResultTile - 3.
SearchGroupHeader - 4.
SearchResultSkeleton - 5.
SearchEmptyState/SearchErrorState - 6.
SearchLanding(zero-query content) - 7.
RecentSearchChip(forward-looking) - 8. Shared components consumed
- 9. Layout containers
Module-specific widgets for global search. Shared primitives come from 00-shared/03_Component_Library.md (authoritative); this file adds the search-specific compositions and their contracts. All names are Flutter widget names
(forward-looking).
1. SearchBarField
- Extends:
AppTextField(shared) with search-prefix icon. - Props:
hint,controller,onQueryChanged(String),onSubmit(String),debounceMs(default 300),enabled. - Internal: 300 ms debounce
Timer(cancel on dispose/restart — 06 §1.3); clear ✕ button; optionalscanaction slot(forward-looking). - Focus:
autofocusfalse on shell; true when landing on/search. - Semantics: hint acts as label; clear button labeled "Clear search".
- Keyboard:
textInputAction: search, autocorrect off,enableSuggestions: false.
2. SearchResultTile
- Extends:
AppListTile. - Props:
result: SearchResult(search.service.ts:10-16),onTap. - Render: avatar (initials / entity icon),
title,description(2-line ellipsis), up to 2AppTags + "+n" fortags(search-indexer.service.ts:83-86). - Missing fields: omit subtitle if
description == ''; omit tags if empty array (schema defaults''/[]—search-index.schema.ts:18-22). - Type icon map (proposed): Student→graduation cap, User→person, Teacher→ presentation, Staff→briefcase, Parent→family, Book→book, Organization→ building, Lead→funnel, Announcement→megaphone. Fallback: generic search icon.
3. SearchGroupHeader
- Props:
entityType,pageCount,onSeeAll. - Render: type label + "N on this page" caption + "See all" text button
→ sets
entityTypefilter (search-query.dto.ts:11-14). - Count caveat:
pageCountis the per-page count, never a global total (search.service.ts:44-49) — the caption text says "N in this view". - Semantics: heading level 2; see-all button labeled
"See all <type>".
4. SearchResultSkeleton
- Extends:
AppSkeleton(shared). Three shimmer rows of tile shape (avatar + 2 lines). Used only for first query (06 §2.2); refinements keep prior content.
5. SearchEmptyState / SearchErrorState
- Thin wrappers over shared
AppEmptyState/AppErrorState(00-shared/03) with module copy ("No results for “q”"; retry re-runs the query — 06 §2.4-2.5).
6. SearchLanding (zero-query content)
- Icon + "Search across your school" +
AppChips: "Student name", "Admission no.", "Book title", "Announcement". Tap fills the bar (05 §4). Analytics:search.landing.chip.tap(topic)(proposed).
7. RecentSearchChip (forward-looking)
- Client-persisted history item (device storage only — ledger B5); single tap re-runs query. No backend history endpoint.
8. Shared components consumed
AppTextField, AppListTile, AppAvatar, AppChips/AppTag, AppSkeleton,
AppEmptyState, AppErrorState, AppOfflineBanner, AppSpinner,
AppButton (clear/back CTAs), AppBadge (group count). Motion tokens
m-fast/m-base from 00-shared/08; tokens from 00-shared/02.
9. Layout containers
| Name | Use |
|---|---|
SearchResultsGroup | vertical group: header + SearchResultTiles |
SearchResultsGridView | tablet/desktop two-column grouping (06 §6) |
SearchBottomLoader | infinite-scroll loading row (06 §2.3) |
08 — Form Specifications (Search Module)
Specification of the search input (the module's only form element). Backend constraints are exact from
search-query.dto.ts; client rules are(proposed)where marked. There is no multi-field form in this module.
1. Search input — the only field
1.1 Server contract (exact)
| Property | Value | Source |
|---|---|---|
| Name | q | search-query.dto.ts:6-9 |
| Type | string, optional | @IsString @IsOptional (:7-9) |
| Max length | none (unbounded) | DTO has no @MaxLength |
| Min length | none (empty allowed) | empty/whitespace → empty result, not an error (search.service.ts:28-30) |
| Other params | entityType (optional string), page (≥1), limit (1–50) | search-query.dto.ts:11-29 |
1.2 Client rules (proposed) — fill the server's gaps
| Rule | Value | Rationale |
|---|---|---|
| Trim | leading/trailing whitespace stripped before send | server compares q?.trim() (search.service.ts:28-30) — trailing spaces would still match server-side but inflate tokens |
| Empty guard | never send blank q; show landing instead | server returns data: [] (search.service.ts:28-30) — a wasted round trip |
| Max input length | 100 chars | server is unbounded; beyond 100 the $text query degrades (search-index.repository.ts:24-33); no server error exists, so cap client-side |
| Debounce | 300 ms after last keystroke (06 §1.3) | each search = 2 queries (search.service.ts:36-37) |
| Whitespace collapse | internal runs collapsed to single space (proposed) | avoids empty tokens in $text |
| Min chars | 1 (no enforced minimum) | server matches single characters via fallback regex (search-index.repository.ts:36-50); client still debounces to protect the index |
1.3 Validation behavior (client-side, none server-side)
Server-side validation fires only for non-string/negative/non-integer
params → 400 VALIDATION_ERROR with per-field details (00-shared/07 §3).
The client prevents these by construction (int types on page/limit,
string on q). No inline field errors exist on this input; failures surface
as screen-level AppErrorState (06 §2.5).
1.4 Special characters
- Regex fallback risk: the fallback builds
{ $regex: q, $options: 'i' }from the raw query (search-index.repository.ts:40). Unescaped regex metacharacters (( ) [ ] { } * + ? ^ $ | . \) can throw a server error on that path. The client escapes nothing today — QA-14 §6 covers testing; a client-side escape(proposed)is the mitigation. - Text-index behavior:
$texttokenizes; punctuation is largely ignored by Mongo (search-index.repository.ts:24-33) — soADM-20vsADM 20can return different results; fallback covers substring cases.
1.5 Autofill / IME
autofillHints: none(search is not a stored identity field).- IME composing: debounce timer starts only after composition ends (CJK-safe, 06 §6).
textInputAction: search→ submits and navigates to/search.
1.6 Related "forms"
| Control | Spec |
|---|---|
| See-all filter | single-select of entityType via chip row; maps to entityType param (search-query.dto.ts:11-14); client remembers it in cubit state |
| Clear | ✕ button: clears text + entityType, resets to landing, cancels debounce |
| Example chips (landing) | fill bar with sample query, trigger debounce (07 §6) |
2. Accessibility of the input
- Label/hint linked to the field semantics (00-shared/09).
- Clear and scan buttons are labeled controls, not icon-only.
- Results count announced via live region after each successful response.
- Focus order: bar → groups → per-row actions.
3. Data sent vs data shown
| Field | Sent | Shown |
|---|---|---|
q | trimmed, whitespace-collapsed | as typed (original casing preserved) |
entityType | one of 9 known types | group headers + filter chips |
page, limit | ints | pagination spinner only |
09 — User Behaviour (Search Module)
- 1. Typing behaviour
- 2. Query vocabulary by intent
- 3. Result-consumption behaviour
- 4. Zero-result behaviour
- 5. Failure-tolerance behaviour
- 6. Freshness expectations
- 7. Power-user patterns
- 8. Behavioural metrics (proposed)
Observed and designed-for behavior patterns around global search. Backend behaviors cited with source; client behaviors
(proposed).
1. Typing behaviour
- Users type partial, unordered tokens ("rah sharma" for Rahul Sharma).
- Mongo
$texttreats space-separated tokens as AND-ish word search; the fallback regex treats the whole string as one substring (search-index.repository.ts:24-50) — so multi-word partials behave differently between paths. Debounce (300 ms, 06 §1.3) absorbs bursts. - Users do not press Enter; they expect live results (submit exists but is secondary).
2. Query vocabulary by intent
| Intent | Query shape | Matched fields (indexer) |
|---|---|---|
| Find a person | name fragment | title = name / firstName+lastName (search-indexer.service.ts:68-75) |
| Find by roll/admission no | ADM-2024-118 | title = admissionNumber (:71) |
| Find by email | sharma@ | description = email (:77-79) |
| Find a book | title words / author | title, description (:68-81) |
| Find an announcement | keyword | description = body (:80) |
| Find by department/grade | "grade 5", "science dept" | tags = role/department/grade (:83-86) |
3. Result-consumption behaviour
- Users scan group headers first, then the top 2–3 rows per group — group order matters (people-first proposal in 06 §2.3).
- Users rarely go past page 1 of the aggregate; they use "See all" for a type when they need depth (05 §3).
- Result count is an expectation cue: with
meta.totalItems(search.service.ts:59) users expect a total, but per-type counts are per-page only (search.service.ts:44-49) — copy must not claim totals (07 §3).
4. Zero-result behaviour
- Users re-type with fewer words (fallback regex substring helps here —
search-index.repository.ts:36-50). - Users then clear and try a different field (email vs name). Landing chips (07 §6) pre-empt this by advertising the field vocabulary.
5. Failure-tolerance behaviour
- Server auto-fallback (
search.service.ts:35-41) means users see results even when$textbreaks — most users will never observe the fallback. - When offline: users retry on connectivity restore (banner + retry, 06 §2.5); cached last results are acceptable interim state (00-shared/10 §2).
6. Freshness expectations
- Users expect immediate visibility of edits: rename a student → search
shows the new name (event-driven upsert,
search-indexer.service.ts:90-96). - Hidden surprise: leads and announcements never re-sync on update/delete
(
search-indexer.service.ts:29-31) — stale rows persist; mitigate with the(planned)reindex job and surfaceupdatedAt(06 §5).
7. Power-user patterns
- Keyboard
/to focus the bar,Escto clear (desktop — 06 §6). - Direct deep links into
/search?q=(forward-looking)from notifications/push(forward-looking)— ledger B3. - Scanning a printed admission number into the bar
(forward-looking)— ledger B4.
8. Behavioural metrics (proposed)
| Metric | Definition |
|---|---|
| Search latency p95 | time from debounce fire → results rendered (< 1 s target) |
| Zero-result rate | empty responses / total searches (search.service.ts:28-30 path excluded) |
| Fallback rate | how often the regex path is hit (search.service.ts:38-41) |
| See-all usage | drill-downs per session (05 §3) |
| Refinement rate | queries changed before any tap |
10 — Interaction Specification (Search Module)
- 1. Interaction states (per 06 §2)
- 2. Debounce interaction (exact timing)
- 3. Query transitions
- 4. Navigation interactions
- 5. Motion & animation (tokens from 00-shared/08)
- 6. Input handling rules
- 7. Offline / degraded interaction
- 8. Analytics events (proposed)
Interaction-level contract for search: state transitions, timing, motion, input handling. Shared interaction/motion conventions from 00-shared/08_Interaction_&_Motion.md; this file is module-specific.
1. Interaction states (per 06 §2)
| State | Trigger | Visual | Exit |
|---|---|---|---|
| idle | no query | landing (07 §6) | type → typing |
| typing | keystroke while composing | bar spinner off; no network | debounce expiry → loading |
| loading | debounce fire / submit / page fetch / retry | previous results dimmed; bar spinner; first query → skeleton (07 §4) | response → success/empty/error |
| success | 200 with data | grouped tiles; new groups animate in | scroll → loading(page); type → typing; tap → navigate |
| empty | 200 with data: [] | AppEmptyState (06 §2.4) | clear → idle; type → typing |
| error | network/401/403/429/5xx | AppErrorState (06 §2.5) | retry → loading; back → previous |
| permission | direct deep link without search perm (permissions.constants.ts:93) | permission state (06 §2.6) | n/a |
2. Debounce interaction (exact timing)
- Keystroke →
SearchBarFieldrestarts a 300 msTimer. - Composition end (IME) starts the timer (CJK-safe).
- Timer fires →
SearchCubit.queryChanged→ stale-guard token++ →loading→ repository call (13 §3). - Any earlier in-flight response whose token is stale is dropped.
- Empty input cancels the timer immediately (no request, 08 §1.2).
3. Query transitions
| From | To | Behavior |
|---|---|---|
q=A (results shown) | q=B | keep A results visible while B loads; replace on B success — never blank the list (06 §2.2) |
| aggregate view | see-all type=X | entityType=X set, page 1, list collapses to one group; back restores previous query + type |
| see-all | back | restore aggregate view from cubit state (no refetch needed if unchanged) |
| pagination | append | page+1 appended; no scroll jump; scroll position preserved |
4. Navigation interactions
- Tap tile → owner-module detail route (Students
/students/:id, etc.) — SearchModule only supplies the pointer (entityId,entityType,search.service.ts:11-12). - Back from detail → results preserved as-is (cubit state survives).
- Deep link
/search?q=…(forward-looking): prefill bar, run query.
5. Motion & animation (tokens from 00-shared/08)
| Interaction | Motion |
|---|---|
| Focus into bar | underline grow m-fast (150 ms) |
| New group appears | fade + 4 px rise m-base (250 ms) |
| Group removal | fade out 150 ms, list reflows |
| Row tap | standard ripple; navigation slide m-base |
| Clear (✕) | bar resets to idle m-fast; landing fade-in m-base |
| Error state | shake m-fast on retry failure (match auth module convention) |
6. Input handling rules
textInputAction.searchsubmits;Esc(desktop) clears;/(desktop) focuses bar (06 §6).- No IME debounce while composing (08 §1.5).
- Focus: bar auto-focused on
/searchentry; cleared on navigation.
7. Offline / degraded interaction
- Offline → banner + last results retained (if any) + retry on restore (00-shared/10 §2).
- Server-side fallback (
search.service.ts:35-41) is invisible to the user; client does not show a "degraded" state (order may differ — fallback has no textScore sort,search-index.repository.ts:49).
8. Analytics events (proposed)
search.bar.focus, search.bar.submit, search.bar.clear,
search.landing.chip.tap, search.results.shown(count),
search.results.empty, search.result.tap(entityType),
search.seeall.tap(type), search.page.next, search.error.{server,offline,retry}.
All (proposed) — SDK open (00-shared/12 A4).
11 — Design System Mapping (Search Module)
- 1. Tokens used
- 2. Component mapping
- 3. Iconography
- 4. Typography rules
- 5. Theme variants
- 6. What the module does NOT customize
Maps every search surface to shared design-system tokens and components (00-shared/02_Design_Tokens.md, 00-shared/04_Design_System_Mapping.md, 00-shared/03_Component_Library.md). No new tokens are invented; module components (07) are compositions of shared primitives.
1. Tokens used
| Token class | Usage in search | Shared source |
|---|---|---|
| Color | color.surface (bar), color.primary (focus underline, see-all), color.text.secondary (hint/captions), color.error (error state) | 00-shared/02 |
| Spacing | 8/12/16 grid: bar padding 16, group gap 16, tile gap 8 | 00-shared/02 |
| Radii | bar 24 dp (pill), tiles 12 dp | 00-shared/02 |
| Elevation | none on results (flat list); shadow only on bar when focused | 00-shared/02 |
| Type scale | titleMedium (tile title), bodyMedium (description), labelSmall (captions/tags) | 00-shared/02 |
| Motion | m-fast 150 ms, m-base 250 ms (10 §5) | 00-shared/08 |
| Semantics | heading levels on group headers, live region on count | 00-shared/09 |
2. Component mapping
| Shared component (00-shared/03) | Search usage | Module wrapper |
|---|---|---|
AppTextField | search input base | SearchBarField (07 §1) |
AppListTile | result row base | SearchResultTile (07 §2) |
AppAvatar | leading avatar / type icon | in SearchResultTile |
AppChips / AppTag | landing examples; result tags (role/dept/grade — search-indexer.service.ts:83-86) | 07 §2,6 |
AppSkeleton | first-query shimmer | SearchResultSkeleton (07 §4) |
AppEmptyState | zero results / landing | SearchEmptyState (07 §5) |
AppErrorState | error + retry | SearchErrorState (07 §5) |
AppOfflineBanner | offline banner | direct use |
AppSpinner | bar inline spinner, bottom loader | direct use |
AppButton | clear/back CTAs | direct use |
AppBadge | group count badge (per-page counts — 07 §3) | in SearchGroupHeader |
3. Iconography
Entity-type icons (proposed set, 07 §2): Student, User, Teacher, Staff,
Parent, Book, Organization, Lead, Announcement. Rules: monochrome
color.text.secondary; avatar fallback = initials from title
(search.service.ts:13).
4. Typography rules
- Titles never truncate to one line without ellipsis; descriptions max 2 lines.
- Query text in the bar renders as typed (casing preserved — 08 §3).
- "No results for “q”" quotes the raw query verbatim.
5. Theme variants
| Variant | Change |
|---|---|
| Dark theme | surface/elevation tokens only; no layout change |
| High contrast | focus underline 2 dp; tag borders instead of fills |
| Text scale ×1.5+ | groups remain scrollable; tiles stack (a11y, 00-shared/09) |
6. What the module does NOT customize
No new color roles, no custom type scale, no custom motion curves; the search surface is a pure composition of shared primitives + the 8 module wrappers in 07.
12 — API Mapping (Search Module)
- 1. Endpoint inventory
- 2. Query parameters (exact)
- 3. Success response (exact shape)
- 4. Empty query response
- 5. Error responses (client-relevant)
- 6. Request example (curl)
- 7. Client mapping (widget → endpoint)
- 8. Non-goals
Exact wire contract for global search. Envelope shapes are authoritative from 00-shared/07_API_Conventions.md (success/error envelopes, pagination meta, error codes, rate limits). Endpoint facts from
search.controller.ts,search.service.ts,search-query.dto.ts.
1. Endpoint inventory
| Method | Path | Permission | Source |
|---|---|---|---|
| GET | /api/v1/search | search | search.controller.ts:11,15-16; permissions.constants.ts:93 |
- Base URL
https://api.<domain>/api/v1(00-shared/07 §1). - Auth:
Authorization: Bearer <accessToken>—JwtAuthGuard(search.controller.ts:3,10); Swagger@ApiBearerAuth()(:9). - This is the only endpoint in the module. Plan-only:
(planned)coaching-index extension needs no new endpoint (sameGET /search—docs/IMPLEMENTATION_PLAN.md:771).
2. Query parameters (exact)
| Param | Type | Default | Constraint | Source |
|---|---|---|---|---|
q | string | — | optional, no length cap | search-query.dto.ts:6-9 |
entityType | string | — | optional, free-form (client sends known types) | search-query.dto.ts:11-14 |
page | int | 1 | ≥ 1 | search-query.dto.ts:16-21 |
limit | int | 20 | 1..50 | search-query.dto.ts:23-29 |
Out-of-range page/limit, non-integer values → 400 VALIDATION_ERROR
(00-shared/07 §3).
3. Success response (exact shape)
{
"success": true,
"message": "OK",
"data": [
{
"entityType": "Student",
"entityId": "66f1c0a5e8b2c1d4f5a6b7c8",
"title": "Rahul Sharma",
"description": "rahul@school.edu",
"tags": ["grade5"]
}
],
"meta": { "page": 1, "limit": 20, "totalItems": 1, "totalPages": 1,
"hasNext": false, "hasPrevious": false },
"timestamp": "2026-08-03T09:00:00.000Z",
"requestId": "req-…"
}
data= array ofSearchResult(search.service.ts:10-16,51-57).meta=buildPaginationMeta(page, limit, totalItems)(pagination-query.dto.ts:41-55); notetotalPagesnever drops below 1 (:46).totalItems= full-text count (search.service.ts:37); whenentityTypefiltering trims a page,totalItemsis replaced by the filtered page length (search.service.ts:44-49) — see QA-14 §7.- Relevance score (
textScore) is used for sort only and never serialized (search-index.repository.ts:28-33;search.service.ts:51-57).
4. Empty query response
q blank/whitespace → data: [], meta.totalItems: 0, totalPages: 1
(search.service.ts:28-30). 200, not an error.
5. Error responses (client-relevant)
| HTTP | Code | When | Source |
|---|---|---|---|
| 400 | VALIDATION_ERROR | invalid page/limit/types | search-query.dto.ts; 00-shared/07 §3 |
| 401 | UNAUTHENTICATED | missing/expired token | JwtAuthGuard (search.controller.ts:10) |
| 403 | PERMISSION_DENIED | no search permission | @Permissions('search') (search.controller.ts:16; permissions.constants.ts:93) |
| 429 | RATE_LIMITED | per-tier limits (00-shared/07 §4) | global rate limit guards |
| 5xx | INTERNAL_SERVER_ERROR | internal (incl. regex-fallback failure) | 00-shared/07 §3 |
Server-side resilience: $text failure is caught and retried via regex
fallback before any error reaches the client (search.service.ts:35-41).
6. Request example (curl)
GET /api/v1/search?q=rahul%20sharma&entityType=Student&page=1&limit=20
Authorization: Bearer <accessToken>
7. Client mapping (widget → endpoint)
Screen (05) | Request | Key handling |
|---|---|---|
| Search bar | GET /search?q=… (debounced 300 ms) | drop stale responses (13 §3) |
| Results screen | same + infinite scroll (page++) | append; meta.hasNext gate (pagination-query.dto.ts:52) |
| See-all | &entityType=X | replace list, page 1 |
| Refresh | same query, page 1 | replace list |
8. Non-goals
- No
POST /search, no bulk, no suggestions, no history endpoints — anything beyondGET /searchis(forward-looking)/(planned). - Search results are not cached in Redis today —
RedisCacheService(redis-cache.service.ts:16-19) is available but unwired(proposed).
13 — State Management (Search Module)
- 1. Cubits
- 2. Repository
- 3. Debounce + stale-guard flow (exact)
- 4. Entity-type filter state
- 5. Failure & retry
- 6. What is NOT in state
Client state architecture for global search. Conventions from 00-shared/06_State_Management.md (flutter_bloc,
get_itDI, repository layer withdio, no server-state libs). Search is a pure server-state surface — no local persistence(forward-looking)history aside.
1. Cubits
1.1 SearchCubit (results screen + in-shell bar share one instance)
| State | Fields |
|---|---|
SearchInitial | — |
SearchLoading | previous SearchResults? (kept for dimmed view) |
SearchSuccess | SearchResults (query, entityType, list, PaginationMeta) |
SearchEmpty | query (raw, for copy) |
SearchError | ApiException(code, status, message), query |
SearchResults model mirrors SearchResult[] + PaginationMeta
(search.service.ts:10-16, pagination-query.dto.ts:32-39).
1.2 SearchHistoryCubit (forward-looking)
Device-storage recent queries (ledger B5 — server has no history endpoint).
2. Repository
SearchRepository (single method):
Future<SearchPage> search({
required String q, String? entityType, int page = 1, int limit = 20,
}) async => /* GET /search, maps envelope → SearchPage */;
- Only layer touching HTTP (
00-shared/06 §2); throws typedApiException(401/403/429/5xx —12 §5). - No caching in this layer today — matches backend (Redis unwired,
redis-cache.service.ts:16-19); add(proposed)TTL cache keyedsearch:{q}:{type}:{page}if latency budgets demand (00-shared/10 §1).
3. Debounce + stale-guard flow (exact)
sequenceDiagram
participant U as User
participant B as SearchBarField
participant C as SearchCubit
participant R as SearchRepository
participant S as API (GET /search)
U->>B: type "rah"
B->>B: restart 300ms Timer
U->>B: type "rahul sharma"
B->>B: restart 300ms Timer (cancel prev)
B->>C: onQueryChanged("rahul sharma")
C->>C: guardToken++; emit Loading(prevResults)
C->>R: search(q, page=1)
R->>S: GET /search?q=rahul+sharma
S-->>R: 200 {data, meta}
R-->>C: SearchPage
C->>C: token fresh? yes → emit Success(results)
Note over C: stale responses (token mismatch) discarded
U->>B: type "rahul sharm"
B->>C: onQueryChanged (timer)
Note over C,R,S: same flow; old in-flight response now stale → dropped
Rules (06 §1.3, 10 §2):
- Timer restarts on every keystroke; fires only after 300 ms of silence.
- Submit (
textInputAction.search) skips the timer. - Guard token: each new query invalidates earlier in-flight responses — prevents out-of-order rendering.
- Pagination:
loadMore()guarded bymeta.hasNext(pagination-query.dto.ts:52) and!isLoadingPage; appends, never replaces.
4. Entity-type filter state
entityTypelives in the cubit, not in route params (keeps back-navigation cheap,10 §3); route params optional(forward-looking)for deep links.- See-all sets
entityType+ page 1; clearing restores the aggregate. - Back from detail: cubit survives (app-shell scope), so results are intact.
5. Failure & retry
SearchError→AppErrorStateretry re-emits the same query (token++, page 1).- 401 → global session-expiry flow (
00-shared/10 §3), query preserved. - Offline → banner + last
SearchSuccessretained (10 §7).
6. What is NOT in state
- No per-type totals (server cannot provide them beyond the fetched page —
search.service.ts:44-49). - No debounce state (owned by the widget's
Timer). - No history/suggestions (
(forward-looking)). - No Redis/persistent cache (
(proposed)).
14 — QA Checklist (Search Module)
- 1. Query contract
- 2. Minimum characters
- 3. Special characters
- 4. Tenant isolation
- 5. Result limits & pagination
- 6. Stale data
- 7. Fallback parity
- 8. Scoring
- 9. Security & auth
- 10. UX & a11y
- 11. Performance
Module-specific quality gates on top of 00-shared/10_QA_Baseline.md (performance budgets, offline matrix, security checklist, device matrix). Each item is traceable to source.
1. Query contract
-
qis trimmed; whitespace-onlyqis never sent (server returnsdata: []—search.service.ts:28-30). -
page ≥ 1,limit ∈ 1..50(defaults 1/20 —search-query.dto.ts:16-29); values outside → server400 VALIDATION_ERROR. - Debounce fires only after 300 ms of keystroke silence; submit skips it (13 §3).
- Stale responses dropped (guard token) — rapid typing never renders out-of-order results.
2. Minimum characters
-
Server accepts 1-char queries (no
@Minonq—search-query.dto.ts:6-9); verify 1-char results render and the fallback path does not degrade response time (regex full-scan —search-index.repository.ts:36-50). - No query → landing screen, zero network calls.
3. Special characters
-
( ) [ ] { } * + ? ^ $ | . \insideqmust never 500. Risk: the fallback builds{ $regex: q, $options: 'i' }from raw input (search-index.repository.ts:40) — invalid regex throws. Gap: mitigation(proposed)is client-side escaping (08 §1.4). -
Punctuation in
$textqueries (ADM-20,Mr. John) — verify expected match behavior vs fallback (tokenization differs — 09 §1). -
%,&,+,=, UTF-8 (Iñigo, Școala) round-trip through URL encoding and match by substring/word. - 100+ char queries: server unbounded (08 §1.2) — confirm p95 latency still within budget (00-shared/10 §1).
4. Tenant isolation
-
Cross-tenant rows never leak: every query uses
scopedFilter(search-index.repository.ts:24-26) overBaseSchema.tenantId(base.schema.ts:9-11); upserts key on(tenantId, entityType, entityId)(search-index.repository.ts:84-89). - Two tenants searching the same string see disjoint results.
-
entityIdfrom tenant A is not resolvable in tenant B (result rows are pointers into owner modules — 12 §8).
5. Result limits & pagination
-
limitrespected (max 50) — server slices via.skip().limit()(search-index.repository.ts:32-33). -
Pagination walk:
meta.hasNexttrue → page+1 appends; final page stops (pagination-query.dto.ts:46-54). -
Known bug-behavior (flagged):
entityTypefiltering happens on the fetched page only (search.service.ts:44-49); withlimit=20and 30 Students behind 50 mixed rows, page 1 shows 20 Students but "See all" page 2 may show none/partial — verify drill-down UX copy ("N in this view", 07 §3) and consider a(planned)server-side type filter.
6. Stale data
-
Rename student/teacher/staff/parent/book/user → re-search within
seconds shows new title (event → upsert —
search-indexer.service.ts:48-96). -
Soft-delete an entity → row disappears from search (
removeByEntitysetsisDeleted—search-index.repository.ts:92-98; scoped filter excludes —base.schema.ts:20-21). -
Known gap:
LeadUpdated,LeadDeleted,AnnouncementUpdated,AnnouncementDeletedare unmapped (search-indexer.service.ts:29-31) → edited/deleted leads & announcements persist stale rows. Verify the(planned)reindex job resolves; until then document behavior. -
Indexer idempotency: duplicate events upsert, never duplicate rows
(
search-index.repository.ts:85-89).
7. Fallback parity
-
Force
$textfailure (test hook) → regex fallback returns results,totalItemsconsistent per path (search.service.ts:35-41). -
Fallback order differs (no textScore sort —
search-index.repository.ts:49): acceptable, verify no UI regression.
8. Scoring
-
Exact-title match ranks above substring (
$texttextScore sort —search-index.repository.ts:28-33); score never serialized (search.service.ts:51-57).
9. Security & auth
-
401 unauthenticated →
UNAUTHENTICATED; 403 withoutsearchperm (permissions.constants.ts:93) →PERMISSION_DENIED(12 §5). - No query text in crash logs or analytics payloads beyond the analytics contract (00-shared/10 §3).
- Rate limits respected; 429 handled with guidance (00-shared/07 §4).
10. UX & a11y
- 1-char queries, 300 ms debounce → no input lag, no flicker (prior results dim, 06 §2.2).
- Zero-results copy quotes raw query; clear CTA returns to landing.
-
Live-region announces result count; group headers are headings;
tiles labeled
"{title}, {type}, {description}"(07 §2). - Offline → banner + cached last results + retry on restore (00-shared/10 §2).
-
Keyboard:
/focuses,Escclears, enter submits (desktop — 06 §6).
11. Performance
-
Search p95 < 1 s (target) — 2 queries per search (search + count,
search.service.ts:36-37). -
No duplicate parallel requests for the same
(q, type, page)key (00-shared/10 §1). - Long lists: lazy tile rendering, no rebuild storm on pagination append.
15 — Flutter Implementation Guide (Search Module)
- 1. Feature folder
- 2. Models & mapping (exact shape)
- 3. Repository
- 4. Cubit (see
13for the full state machine) - 5. Debounce widget
- 6. Widget tree
- 7. Router entries
- 8. Tests
- 9. Non-goals in v1
How to build the search feature in the Flutter client. Extends 00-shared/11_Flutter_App_Architecture.md (folder layout, DI, dio, go_router, flutter_bloc). Forward-looking spec — no client repo exists yet (ledger A1).
1. Feature folder
features/search/
├── data/
│ ├── dto/search_result_dto.dart # envelope → SearchResult
│ └── repositories/search_repository.dart
├── domain/
│ └── models/search_result.dart # entityType, entityId, title, description, tags
├── presentation/
│ ├── cubit/search_cubit.dart
│ ├── widgets/search_bar_field.dart
│ ├── widgets/search_result_tile.dart
│ ├── widgets/search_group_header.dart
│ ├── widgets/search_results_screen.dart
│ └── widgets/search_landing.dart
└── test/
├── search_cubit_test.dart
└── search_repository_test.dart
2. Models & mapping (exact shape)
class SearchResult {
final String entityType, entityId, title, description;
final List<String> tags;
// fromJson: {entityType, entityId, title, description, tags}
// see search.service.ts:10-16
}
class SearchPage {
final List<SearchResult> items;
final PaginationMeta meta; // page, limit, totalItems, totalPages, hasNext, hasPrevious
// pagination-query.dto.ts:32-39
}
3. Repository
class SearchRepository {
SearchRepository(this._dio);
final Dio _dio;
Future<SearchPage> search(String q, {String? entityType, int page = 1, int limit = 20}) async {
final res = await _dio.get('/search', queryParameters: {
'q': q, if (entityType != null) 'entityType': entityType,
'page': page, 'limit': limit,
});
// map envelope.data + envelope.meta; throw ApiException on !=200
}
}
- Base URL
/api/v1prefix handled byAppDio(00-shared/11 §2). - Validation rules enforced by the server (
search-query.dto.ts:16-29) — keep client values in range; treat400 VALIDATION_ERRORas a bug. - No client cache initially — backend doesn't cache either
(
redis-cache.service.tsunwired); add(proposed)in-memory TTL cache only if the 2-query pattern (search.service.ts:36-37) misses budget.
4. Cubit (see 13 for the full state machine)
sealed class SearchState {}
class SearchInitial extends SearchState {}
class SearchLoading extends SearchState { final SearchPage? previous; }
class SearchSuccess extends SearchState { final SearchPage page; }
class SearchEmpty extends SearchState { final String query; }
class SearchError extends SearchState { final ApiException error; final String query; }
class SearchCubit extends Cubit<SearchState> {
SearchCubit(this._repo) : super(SearchInitial());
final SearchRepository _repo;
String _query = ''; String? _type; int _page = 1; int _guard = 0;
Future<void> queryChanged(String q) async { /* 300ms debounce handled by widget */
_query = q.trim(); if (_query.isEmpty) { emit(SearchInitial()); return; }
final token = ++_guard; _page = 1;
emit(SearchLoading(previous: currentState is SearchSuccess ? (currentState as SearchSuccess).page : null));
try {
final page = await _repo.search(_query, entityType: _type);
if (token != _guard) return; // stale guard: drop out-of-order response
emit(page.items.isEmpty ? SearchEmpty(_query) : SearchSuccess(page));
} on ApiException catch (e) {
if (token != _guard) return;
emit(SearchError(e, _query));
}
}
// setType(String?) → reset page 1, re-run; loadMore() → append if meta.hasNext
}
5. Debounce widget
class SearchBarField extends StatefulWidget { /* ... */ }
// _onChanged: Timer? _t; _t?.cancel(); _t = Timer(Duration(milliseconds: 300),
// () => widget.onQueryChanged(value.trim()));
// onSubmitted: _t?.cancel(); widget.onSubmit(value.trim());
Rules: timer restart per keystroke; dispose() cancels; IME composing
ignored until composition end (08 §1.5).
6. Widget tree
Shell (AppBar)
└── SearchBarField
└── GoRouter → /search
└── SearchResultsScreen
├── SearchLanding (SearchInitial)
├── SearchResultSkeleton (first loading)
├── SearchErrorState (SearchError)
├── SearchEmptyState (SearchEmpty)
└── CustomScrollView (SearchSuccess)
├── SearchGroupHeader ×N (per entityType — search.service.ts:11)
├── SearchResultTile ×N (onTap → owner detail route)
└── SearchBottomLoader (infinite scroll)
Grouping helper: groupBy(entityType) preserves first-seen order; group
order proposed in 06 §2.3.
7. Router entries
GoRoute(path: '/search', builder: SearchResultsScreen.new,
queryParameters: {'q': ..., 'type': ...}); // type maps to entityType (forward-looking deep links)
8. Tests
- Cubit: debounce via fake async; stale-guard (two rapid queries, first
response arrives last → discarded); empty →
SearchEmpty; error → retry. - Repository: mocked dio; envelope mapping;
400/401/403/429→ typedApiException(00-shared/06 §2). - Widget: golden for grouped list, empty, skeleton; a11y semantics
labels (
07 §2). - Integration: live tenant — type "rahul", expect Student group, tap →
detail (
14as acceptance list).
9. Non-goals in v1
- No local search history/suggestions (
(forward-looking)). - No QR scan wiring (
(forward-looking)— ledger B4). - No push deep-link handling (
(forward-looking)— ledger B3). - No Redis-backed result cache (
(proposed)).
01 — Product Overview (Settings Module)
- 1. Purpose
- 2. Business goals
- 3. User goals
- 4. Stakeholders
- 5. Why this exists
- 6. Dependencies
- 7. Success metrics
- 8. Edge cases (derived)
- 9. Assumptions (module)
- 10. Open questions (module-level; global ledger in 00-shared/12)
- 11. Glossary (this module)
StudyLyon — multi-tenant ERP / School Management API. This package designs the Settings module client (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, and wire contracts are derived directly from
src/modules/settings/**,src/modules/organizations/**,src/modules/feature-flags/**,src/modules/rbac/permissions.constants.ts,studylyon-blueprint/04-Modules/Organizations.mdand03-Database/COLLECTIONS.md. No feature is invented; gaps are flagged in the Assumptions & Open Questions section.
1. Purpose
Settings is the tenant-scoped key/value configuration store of StudyLyon. It gives each
organization a flat, arbitrarily-typed setting registry — key → value — grouped into six
fixed buckets, read/written through a thin CRUD API.
| Responsibility | Source |
|---|---|
| List all tenant settings (sorted by group then key) | settings.service.ts:10-12 |
List settings of one group (?group= filter) | settings.controller.ts:26-28, settings.service.ts:14-16 |
| Read a single setting by key | settings.controller.ts:31-35, settings.service.ts:18-22 |
| Create-or-update a setting (upsert per key) | settings.controller.ts:37-41, settings.service.ts:24-26 |
| Bulk upsert (sequential per-key) | settings.controller.ts:43-47, settings.service.ts:28-34 |
| Delete a setting (soft delete) | settings.controller.ts:49-53, settings.service.ts:36-41 |
| Tenant isolation on every query/upsert | base.repository.ts:20-30, setting.repository.ts:21-40 |
The module is deliberately thin. It is a generic store: there is no per-key schema, no
setting registry, no validation of values, and no history. The "rich" org-level configuration
lives in a second, separate surface: the settings object embedded in the
organizations document (organization.schema.ts:96-112) with its own endpoints
(GET/PATCH /organizations/:id/settings). The blueprint separates them deliberately —
"Settings separated from organizations to avoid hot-document writes"
(Organizations.md:60), and defines a dedicated organization_settings collection
(COLLECTIONS.md:767-783). Today only the standalone settings collection is
implemented in code; the organization_settings collection is a blueprint concept not yet
in code.
2. Business goals
| Goal | Measure |
|---|---|
| Any org-level knob in one place | All settings CRUD via 5 endpoints; groups cover academic, attendance, grading, notification, theme, general (setting.schema.ts:7-14) |
| Zero-conflict multi-tenant isolation | Unique index {tenantId, key} (setting.schema.ts:38); tenantId injected by repository, never from body (base.repository.ts:33-35) |
| No hot-writes on org document | Standalone collection + upsert semantics (Organizations.md:60) |
| Config written by machines and admins | Idempotent PUT upsert; bulk endpoint for batch imports (settings.service.ts:24-34) |
| Delete ≠ data loss | Soft delete with audit markers (base.repository.ts:68-74) |
3. User goals
- Org admin: browse every org setting in one screen, edit any value with the right input type, save one or many, remove stale keys.
- Setting editor (staff/admin with
settings.*perms): find a key quickly (group filter- client search — the API has no search param), edit JSON/numbers/booleans without breaking values.
- Platform admin: cross-tenant visibility for support (platform admin bypasses the tenant
scope,
base.repository.ts:21-23). - Consumers (other modules / future UI): read settings programmatically via
GET /settings/:keyorGET /settings?group=.
4. Stakeholders
Org admins, delegated setting editors, platform support, module developers (each module
reads its own settings at runtime), QA/design/engineering. Note: today no backend consumer
reads settings — nothing in src/ imports SettingsService outside the settings module
(no cross-module call, per AGENTS.md module-boundary rule); consumption is via API or
future events.
5. Why this exists
Schools differ in grading, attendance rules, notification preferences, and theming. A
generic per-tenant store lets the platform ship one codebase with per-org behavior without
schema migration per feature, and keeps high-frequency config writes off the hot
organizations document (COLLECTIONS.md:781).
6. Dependencies
| Dependency | Role | Source |
|---|---|---|
| Auth (JWT) | every settings endpoint requires a bearer token | settings.controller.ts:12,19; global guards app.module.ts:129-131 |
| RBAC | settings.read/update/delete permission constants exist | permissions.constants.ts:75-77 — not enforced on this controller (OQ-2) |
| TenantContextService | tenant scoping of all queries | setting.repository.ts:16,34; base.repository.ts:20-30 |
| Organizations module | separate embedded settings surface (attendance/academic/theme) | organization.schema.ts:96-112; organizations.controller.ts:56-72 |
| Feature Flags module | sibling boolean config surface | feature-flags.controller.ts:23-58 |
| BaseRepository | soft-delete + version + audit fields | base.schema.ts:8-35, base.repository.ts:32-74 |
| Mongo collection | settings (implemented); organization_settings (blueprint only) | setting.schema.ts:16; COLLECTIONS.md:767-783 |
7. Success metrics
- List load < 300 ms (settings volume per tenant is small — tens to low hundreds of keys).
- Save round-trip < 500 ms p95; upsert idempotent — re-tap safe.
- Zero cross-tenant leaks (unique index + scopedFilter; QA-perm).
- Bulk save of N keys: all-or-nothing perception on UI despite sequential server loop (failure surfaced per item; see OQ-4).
8. Edge cases (derived)
GET /settings/:keymissing key → 404RESOURCE_NOT_FOUND"Setting "key" not found." (settings.service.ts:20).PUT /settingsmissingkeyorvalue→ class-validator has no decorators on them (update-setting.dto.ts:5-10) → Mongooserequiredvalidation error → 500 (OQ-3).DELETE /settings/:key→ soft delete; the unique index{tenantId, key}(setting.schema.ts:38) still holds the soft-deleted doc → re-creating the same key after delete hits a duplicate-key error → 500 (OQ-5).label/descriptionare accepted by the DTO (update-setting.dto.ts:17-25) but the service never persists them — only key/value/group reach the repo (settings.service.ts:25;setting.repository.ts:37).isEncryptedexists on the schema (setting.schema.ts:33-34) but nothing sets it and no encryption path exists (OQ-6).- Bulk update runs a sequential loop, not a transaction (
settings.service.ts:28-34) — a mid-batch failure leaves earlier keys saved. - Platform admin
GET /settingslists settings of all tenants (scope bypass,base.repository.ts:21-23). - Values are untyped:
value: unknown(update-setting.dto.ts:10) and@Prop({type: Object})(setting.schema.ts:21-22) → the client must infer the editor type from the runtime value.
9. Assumptions (module)
- Mobile client is forward-looking: backend is complete; this package is the UI-side spec.
- The settings list is NOT paginated:
findAllreturns a bare array (settings.service.ts:10-12) and the envelope interceptor only addsmetafor{data, meta}payloads (response-envelope.interceptor.ts:25-32,55-59) — the settings list is a single fetch withmetaomitted. - Group set is fixed to the enum:
academic, attendance, grading, notification, theme, general(setting.schema.ts:7-14). ACOACHINGgroup is(planned)—IMPLEMENTATION_PLAN.md:773. - No setting definitions exist: the UI is data-driven (render whatever keys exist); it cannot show a canonical "every setting" catalog, defaults, or per-key docs.
- Settings written via API are immediately consistent for subsequent reads (single Mongo doc, no cache layer in the settings path).
10. Open questions (module-level; global ledger in 00-shared/12)
| # | Item | Impact |
|---|---|---|
| OQ-1 | Which module(s) consume settings at runtime? No consumer exists in src/. | Copy/help text, "who reads this" metadata |
| OQ-2 | settings.read/update/delete (permissions.constants.ts:75-77) are not enforced — the controller has JWT guard only (settings.controller.ts:19) and no @Permissions metadata. Any authenticated tenant user can read/edit/delete every setting. | Role-based UI gating vs server truth; 403 UX |
| OQ-3 | PUT /settings with missing key/value → Mongoose required error → 500 instead of 400 (no @IsString/@IsDefined on the DTO). | Client must always send both; error mapping |
| OQ-4 | Bulk loop is non-transactional (settings.service.ts:28-34) — partial failures possible. Add a transaction or accept per-item results? | Save-all UX, retry semantics |
| OQ-5 | Soft delete + unique {tenantId, key} index = a deleted key can never be recreated (E11000 → 500). Fix: include isDeleted in index or hard-delete. | Delete → re-create flow |
| OQ-6 | isEncrypted is never written; secrets (SMTP creds etc.) are stored plaintext. Encryption pipeline (planned)? | Encrypted-badge UI, secret handling |
| OQ-7 | No history/audit of setting changes exposed (audit module exists; no settings events). History screen (planned). | "Setting detail history" tab |
| OQ-8 | Which settings keys are canonical? No seed/registry anywhere. Coaching expansion will add groups/keys (IMPLEMENTATION_PLAN.md:773). | Editor type heuristics, defaults |
11. Glossary (this module)
| Term | Meaning |
|---|---|
| Setting | One settings doc: {tenantId, key, value(any JSON), group, label?, description?, isEncrypted, createdBy/updatedBy, isDeleted, deletedAt?, deletedBy?, version, createdAt, updatedAt} (setting.schema.ts:17-35, base.schema.ts:10-34) |
| Group | Fixed enum bucket: academic, attendance, grading, notification, theme, general |
| Upsert | findOneAndUpdate(..., {upsert: true}) — create if missing, else update value (+group) (setting.repository.ts:29-40) |
| Embedded settings | The settings object on the organizations doc (attendance/academic/theme) — separate surface |
| Envelope | {success,message,data,meta?,timestamp,requestId} (response-envelope.interceptor.ts:48-59) |
02 — User Personas (Settings Module)
- 1. Org Admin (org_admin role) — flagship user
- 2. Setting Editor (delegated staff — e.g., office manager, ICT coordinator)
- 3. Platform Admin (platform_admin, cross-tenant)
- 4. Config Consumer (machine / other modules)
(planned) - 5. Read-Only Staff / Teacher (everyone else)
- 6. Role → settings-appearance matrix
Roles, goals, permissions, and expected behavior for every consumer of the Settings module. Permission values are exact from
src/modules/rbac/permissions.constants.ts. Critical caveat: the settings controller carries no@Permissionsmetadata (settings.controller.ts:19— JWT guard only), sosettings.read/update/delete(permissions.constants.ts:75-77) are declared but not enforced today (OQ-2). The UI still gates on them for forward-compatibility; the server is the authority only for authentication, not permission.
1. Org Admin (org_admin role) — flagship user
| Attribute | Value |
|---|---|
| Frequency | Daily; config changes weekly (term boundaries, grading, theme) |
| Permissions | All (role seeds ALL_PERMISSIONS, rbac/schemas/role.schema.ts:23 — includes settings.read/update/delete) |
| Goal | See the whole org config in one place; change values with the right input; batch-save term changes |
| Pain points | Free-form keys with no description; not knowing a value's type until it breaks; deleted keys that can't be recreated (OQ-5) |
| Core job | Browse GET /settings (optionally ?group=), edit PUT /settings, batch PUT /settings/bulk, remove DELETE /settings/:key |
| Expected behavior | Group tabs; typed editors per value; immediate save feedback; dirty tracking across edits |
| Mental model | "Settings is my school's control panel." |
2. Setting Editor (delegated staff — e.g., office manager, ICT coordinator)
| Attribute | Value |
|---|---|
| Frequency | Several times/week |
| Permissions | Custom role with settings.read/update (not settings.delete) — once server enforcement lands (OQ-2) |
| Goal | Update attendance thresholds, notification toggles, academic labels without touching org-level or theme |
| Pain points | Accidental bulk-save of half-edited values; JSON fields they can't parse |
| Expected | Group-scoped view (attendance only), per-field save, JSON editor with syntax feedback, "saved" snackbar per key |
| Mental model | "I only touch my group's knobs." |
3. Platform Admin (platform_admin, cross-tenant)
| Attribute | Value |
|---|---|
| Permissions | isPlatformAdmin bypasses the tenant scope entirely (base.repository.ts:21-23; set in jwt-auth.guard.ts:54) |
| Frequency | On-demand support/diagnostics |
| Goal | Compare tenant configs; fix a broken setting during support |
| Restrictions | Platform admin must hold a real tenant JWT; its GET /settings returns all tenants' settings merged (no tenant filter) — the UI must show a "cross-tenant view" warning if surfaced |
| Mental model | "Global view; treat carefully." |
4. Config Consumer (machine / other modules) (planned)
| Attribute | Value |
|---|---|
| Access | GET /settings?group= or GET /settings/:key with a service account (API keys, auth module) |
| Goal | Read org config at runtime (grading scale, working days, theme colors) |
| Status | No consumer exists in src/ today (OQ-1); settings consumption by other modules is future wiring |
| Note | Values are untyped — consumers must handle unknown (setting.schema.ts:21-22) |
5. Read-Only Staff / Teacher (everyone else)
| Attribute | Value |
|---|---|
| Permissions | No settings.* perms in seeded roles (rbac/schemas/role.schema.ts:31-42) |
| Behavior | Never sees the Settings surface in the UI (hidden by permission gating); settings screens are admin-only |
| Caveat | Because the server does not enforce settings.* today, any authenticated user could call the API — UI hiding is the only defense until OQ-2 is resolved |
6. Role → settings-appearance matrix
| Surface | org_admin | setting editor | platform_admin | teacher/staff/parent/student |
|---|---|---|---|---|
/settings section (module) | ✓ | ✓ (granted groups) | ✓ | hidden |
| Settings list + group filter | ✓ | ✓ | ✓ (cross-tenant warning) | — |
| Edit (PUT) | ✓ | ✓ (update perm) | ✓ | — |
| Batch save (PUT /bulk) | ✓ | ✓ | ✓ | — |
| Delete (DELETE) | ✓ | perms settings.delete | ✓ | — |
| Org embedded settings (S4 tabs in Organizations module) | ✓ | — | ✓ | — |
| Feature flags (sibling surface) | ✓ | — | ✓ | — |
Server-side truth: authentication only (JWT). Permission gating is client-side
(route guard reads user.permissions) until @Permissions('settings.*') lands on
settings.controller.ts — mirror the pattern of files.controller.ts:30 /
webhooks.controller.ts:22 when that happens.
03 — User Journeys (Settings Module)
- 1. Browse settings (list + group filter)
- 2. Edit a setting (single upsert)
- 3. Batch save (bulk update)
- 4. Permission denied
- 5. Delete a setting
- 6. Cross-cutting (shared rules)
End-to-end journeys computed from
settings.controller.ts+settings.service.ts+setting.repository.ts. Each journey: entry, intent, decision points, system responses, loading, failures, recovery, exit, back navigation, abandonment, timeout, session expiry, permission denial, offline.(planned)/(forward-looking)per global rules.
1. Browse settings (list + group filter)
entry: Settings section → "Settings" (admin), deep link, dashboard quick-link
intent: see the whole tenant config, grouped
sequenceDiagram
actor U as Admin
participant F as SettingsListPage
participant R as SettingsRepository
participant API as GET /settings
U->>F: open Settings
F->>R: load()
R->>API: GET /api/v1/settings (Bearer)
API-->>R: 200 data: [Setting...] (array, meta omitted — non-paginated)
R-->>F: groupBy(group) + sort (server already sorts group,key)
F-->>U: group tabs + key/value rows
alt tap group chip
U->>F: "Attendance"
F->>R: load(group: 'attendance')
R->>API: GET /api/v1/settings?group=attendance
API-->>F: 200 filtered array
else client search
U->>F: type "grace"
F-->>U: filtered rows (client-side only — API has no q param)
end
- Decision points: group chip (server filter) vs free search (client filter).
- Loading:
AppSkeleton(list); content ≤ 2 s budget (00-shared/10 §1). - Failure covers: 401 → silent refresh → session expiry; 5xx →
AppErrorState+ retry; offline → last-good cache +AppOfflineBanner. - Exit: row tap → setting detail; FAB none (edits happen in-line/detail).
- Empty state: no settings at all → "No settings yet — create one" (
AppEmptyState); group empty → "Nothing in this group". - Permission denial: client route guard rejects without
settings.read→ 403 screen (server does not 403 today — OQ-2).
2. Edit a setting (single upsert)
entry: settings list row tap → detail → edit
intent: change one value safely
sequenceDiagram
actor U as Admin
participant D as SettingDetailPage
participant R as SettingsRepository
participant API as PUT /settings
U->>D: open "attendance.gracePeriod" (number editor)
D-->>U: input type inferred from runtime value (number)
U->>D: 10 → 15
D->>R: save(key, value, group)
R->>API: PUT /api/v1/settings {key, value:15, group:'attendance'}
alt 200
API-->>D: data: updated Setting doc
D-->>U: "Saved" snackbar; value reflects server truth
else 400 VALIDATION_ERROR
API-->>D: details per field (group enum invalid etc.)
D-->>U: field error under group picker
else 5xx
D-->>U: generic + requestId; form kept (retry safe — upsert idempotent)
end
- Save semantics (source): upsert =
findOneAndUpdate({tenantId,key,isDeleted:false}, {$set:{value, group?}}, {upsert:true, new:true})(setting.repository.ts:35-39) — the wholevalueis replaced, never merged.label/descriptionfrom the DTO are dropped by the service (settings.service.ts:25). - Idempotency: PUT is safe to retry; last-write-wins per key.
- Client validation: mirrors the DTO —
keynon-empty string,group∈ enum (update-setting.dto.ts:6-15); value type-check is client-only (server stores any JSON). - Optimistic? No — value is config; server-confirm then reflect
(consistent with
00-shared/06 §3.5safe-mutation policy: write-once/semantic ops). In-line save on the list is the fast path (see 4).
3. Batch save (bulk update)
entry: settings list "Edit" mode or multi-select → Save all
intent: apply many config changes in one action
sequenceDiagram
actor U as Admin
participant F as SettingsListPage
participant R as SettingsRepository
participant API as PUT /settings/bulk
U->>F: edit 5 values across groups
F->>F: track dirty keys client-side
U->>F: "Save all" (only dirty keys serialized)
F->>R: saveAll([{key,value,group} x5])
R->>API: PUT /api/v1/settings/bulk [ ...5 dtos ]
loop each dto (server side)
API->>API: repo.upsert(...) sequential, no transaction
end
API-->>R: 200 data: [Setting...] — all saved
R-->>F: clear dirty set; "5 settings saved" snackbar
- Server contract: loop of individual upserts, results returned as array
(
settings.service.ts:28-34). Not atomic — a mid-batch failure returns 5xx with earlier keys already saved (OQ-4). - Client countermeasure: only dirty keys are sent (no wasted writes); on failure show "Saved N of M" with per-key retry — the retry must re-send the full dirty set (upsert is idempotent, so re-sending saved keys is harmless).
- Abandonment: leaving with dirty keys → "Discard changes?" dialog (client-side only; server has no draft).
4. Permission denied
entry: any settings route/action for a user without settings.* (today: client-side only)
sequenceDiagram
actor U as User (no settings perms)
participant R as Router
participant G as RouteGuard
U->>R: navigate /settings
R->>G: guard check permissions.contains('settings.read')
alt lacks permission
G-->>U: redirect /settings/403 (shared 403 screen)
else has permission
G->>R: allow route
end
Note over U: server today returns 200 for any JWT (OQ-2); once @Permissions lands,<br/>403 PERMISSION_DENIED → same redirect via error code mapping
- Failure: 403 from server (future) → shared 403 screen; hidden nav entries for users
without perms (
00-shared/05 §2); inline action 403 → snackbar + hide action (00-shared/06 §5).
5. Delete a setting
entry: detail screen or row menu → Delete
intent: remove a stale key
sequenceDiagram
actor U as Admin
participant D as SettingDetailPage
participant API as DELETE /settings/:key
U->>D: menu → "Delete"
D->>D: AppDialog confirm (destructive)
U->>D: confirm
D->>API: DELETE /api/v1/settings/:key
alt 200
API-->>D: 200 (void data)
D-->>U: snackbar "Setting deleted"; pop to list (row removed)
else 404 RESOURCE_NOT_FOUND
D-->>U: treat as already deleted; remove row
end
Note over U: soft delete — server keeps doc (isDeleted: true)<br/>(base.repository.ts:68-74). Recreating same key → E11000 → 500 (OQ-5)
6. Cross-cutting (shared rules)
| Aspect | Behavior |
|---|---|
| Timeout | dio 15 s; retry on network failure (upsert safe) |
| Session expiry | 401 → silent refresh → sessionExpired → login; state preserved where safe |
| Offline | list from last-good cache + banner; writes blocked with guidance (no offline queue for settings — 00-shared/07 §10) |
| Abandonment | dirty edits dropped on exit with confirm; no server draft |
Deep links (forward-looking) | studylyon://settings → list; studylyon://settings/:key → detail |
04 — Information Architecture (Settings Module)
- 1. Placement in the app shell
- 2. Data model: groups (fixed enum)
- 3. Route table (module-owned; extends
00-shared/05 §4) - 4. Screen hierarchy
- 5. Modal hierarchy (module, per
00-shared/05 §5) - 6. Screen relationships & state handoff
- 7. Cross-surface: embedded org settings (context, not module-owned)
- 8. Quick actions / FAB / context menus
- 9. Accessibility of IA
Where the Settings module lives in the shell (00-shared/05 §2: Settings →
/settings, role: admin) and how its data surface is organized. Everything derived fromsetting.schema.ts(groups),settings.controller.ts(API), and the organizations module's separate embedded-settings surface.
1. Placement in the app shell
AppShell
└─ Settings (nav destination, admin) ─ /settings
├─ Settings hub (this module): group tabs + key/value list
├─ Roles & permissions (RBAC module: /settings/roles, /settings/members)
├─ Security (Auth module: /settings/security/*)
├─ Organization (/organization — embedded settings + branding + flags)
└─ Profile (avatar menu → /settings/profile)
The module owns only the key/value Settings hub. Sibling surfaces under the same
/settings prefix are owned by other modules (see design-docs/rbac/04, design-docs/auth/04).
2. Data model: groups (fixed enum)
| Group | Enum value | Typical contents (illustrative; no registry exists) | Source |
|---|---|---|---|
| Academic | academic | grading scale, passing %, academic labels | setting.schema.ts:8 |
| Attendance | attendance | grace period, late threshold, working days | setting.schema.ts:9 |
| Grading | grading | grade bands, rounding | setting.schema.ts:10 |
| Notification | notification | channel toggles, digest times | setting.schema.ts:11 |
| Theme | theme | primary/secondary color (parallels org settings.theme) | setting.schema.ts:12 |
| General | general (default) | anything ungrouped | setting.schema.ts:13,24-25 |
COACHING | (planned) | batch defaults, test series config, DPP settings | IMPLEMENTATION_PLAN.md:773 |
Server sorts list by group: 1, key: 1 (settings.service.ts:11) — the UI renders tabs in
enum order (setting.schema.ts:7-14 declaration order) and rows alphabetically per group.
3. Route table (module-owned; extends 00-shared/05 §4)
| Route | Screen | Auth | Source API |
|---|---|---|---|
/settings | Settings list (hub) | JWT + settings.read (client) | GET /settings[?group=] |
/settings/group/:group | Group-filtered list (deep link) | same | GET /settings?group= |
/settings/:key | Setting detail + edit | JWT + settings.read/update | GET /settings/:key, PUT /settings |
/settings/403 | shared permission screen | — | — |
4. Screen hierarchy
flowchart TD
SET[/settings/] --> GRP[/settings/group/attendance/]
SET --> DET[/settings/:key/]
DET --> EDIT[typed editor inline]
SET --> BULK[multi-select → Save all]
subgraph sibling surfaces (other modules)
RBAC[/settings/roles/]
SEC[/settings/security/]
end
- List is the hub; detail is a push (phone) / right pane (tablet+, master-detail ≥840 dp).
- Editing happens in place on the list (inline typed editor per row) or on the detail
page — both call the same
PUT /settingsupsert (see 03 §2, 08).
5. Modal hierarchy (module, per 00-shared/05 §5)
- Snackbar/toast — "Saved", "Setting deleted", "Copied (JSON)".
- Bottom sheet — JSON editor (multi-line, needs height), delete confirm for inline rows.
- Dialog — delete confirm (destructive), "Discard changes?" on dirty exit, JSON parse error details.
- Full-screen page — setting detail (key, group, value, meta).
- Loader — row-level saving spinner; never full-screen dual column (list is cached).
6. Screen relationships & state handoff
- Detail edits return to the list with the row refreshed from the server response
(
data: SettingfromPUT /settings). - Group chip state survives navigation via
SettingsCubit(selected group held in state,13_State_Management.md). - Delete pops detail → list; row removed on server 200 (or 404 treated as removed).
7. Cross-surface: embedded org settings (context, not module-owned)
| Surface | Location | Source |
|---|---|---|
Org embedded settings (attendance/academic/theme objects) | /organization → Settings tabs (Organizations module S4) | organization.schema.ts:96-112; organizations.controller.ts:56-72 |
| Org branding (colors/logo/favicon) | /organization → Branding | organization.schema.ts:114-120 |
| Feature flags (boolean toggles) | /organization → Feature flags | feature-flags.controller.ts:23-58; feature-flag.schema.ts:9-23 |
Design consequence: two surfaces can hold overlapping concepts — settings.theme keys in
this module vs organization.settings.theme + branding (dual-surface confusion, see
design-docs/organizations/09 § — "Which settings apply?" OQ). The Settings hub must carry a
visual note: "Org-level config (attendance, theme, working days) also lives under
Organization" with a cross-link, until the blueprint's organization_settings collection
(COLLECTIONS.md:767-783) consolidates them (planned).
8. Quick actions / FAB / context menus
- FAB (list, extended): "New setting" → creates an unsaved key (group default GENERAL).
- Row menu (
AppMenu): Edit value, Duplicate key (new key copy), Delete. - Multi-select (long-press rows on phone; checkboxes on desktop) → bottom
Save allbar. - Keyboard (desktop):
Ctrl+Ffocuses search (client-side);+/Nnew setting;Esccloses editor/confirm;Ctrl/Cmd+Entersaves from the JSON editor (00-shared/08 §2).
9. Accessibility of IA
- One
headlineMediumper screen; group tabs announced as tab list with selected state. - Focus order: search → group chips → first row; row edit opens editor with focus in value.
- Group chips never color-only — label + selected pill (
00-shared/09 §9).
05 — Screen Inventory (Settings Module)
- Legend
- 1. Settings List (hub) —
/settings - 2. Setting Detail —
/settings/:key - 3. Typed Value Editors (per type — embedded in list row + detail)
- 4. New Setting (FAB flow) — sheet
- 5. Delete Confirm (dialog/sheet)
- 6. History — NOT AVAILABLE
- 7. Cross-surface references (not module-owned)
- Shared components used
- Analytics events (proposed)
- Keyboard / landscape / tablet / desktop
Every screen of the Settings module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in 00-shared/03; module-specific components in 07_Component_Library.md.
Legend
States = idle / loading / success / empty / error(offline, 5xx, 404) / disabled / permission.
Analytics events follow {module}.{screen}.{action} (proposed; SDK open — 00-shared/10 §8).
1. Settings List (hub) — /settings
| Field | Detail |
|---|---|
| Purpose | Browse all tenant settings, grouped; edit in place; batch save |
| Entry | Settings nav destination, deep link, return from detail |
| Exit | detail push, organization cross-link, 403 (no perm) |
| Source | GET /api/v1/settings (all) — array, meta omitted (non-paginated, response-envelope.interceptor.ts:25-32,55-59); GET /settings?group= on chip tap |
| Composition | AppBar (settings icon + "Settings"), AppSearchBar (client-side filter), group AppChips (enum order, setting.schema.ts:7-14), ListView.builder of SettingRow cards, extended AppFAB "New setting" |
| States | loading AppSkeleton(list); empty "No settings yet"; group-empty "Nothing in this group"; error AppErrorState(code, retry); offline banner + cached rows |
| Row | key (mono, titleMedium), value preview (type-chipped: str/num/bool/JSON), group badge on GENERAL rows, encrypted badge if isEncrypted (never today — OQ-6) |
| Multi-select | long-press → selection mode, bottom Save all (N) bar; only dirty rows serialized (03 §3) |
| Permission | settings.read (client guard); settings.update enables editors |
| Analytics | settings.list.view, settings.list.group_tap.{group}, settings.list.search, settings.bulk.save.{n} |
| Adaptive | phone: stacked cards; tablet/desktop: two-pane (list + detail pane) ≥840 dp |
2. Setting Detail — /settings/:key
| Field | Detail |
|---|---|
| Purpose | Read/write one setting with meta |
| Entry | list row tap |
| Exit | back to list; delete pops |
| Source | GET /settings/:key (settings.controller.ts:31-35) — 404 RESOURCE_NOT_FOUND if key missing (settings.service.ts:20) |
| Composition | key header (mono), group picker (AppDropdown of enum), typed value editor (see 6 §S2), label/description read-only placeholders (server drops them — settings.service.ts:25), meta footer (created/updated/version/audit ids when present), Delete (menu) |
| States | loading, loaded, 404 → AppEmptyState "Setting not found" + back |
| Permission | settings.update gates Save; settings.delete gates Delete |
3. Typed Value Editors (per type — embedded in list row + detail)
| Editor | Value type | Notes |
|---|---|---|
| String editor | string | AppTextField; saved as-is |
| Number editor | number | AppTextField numeric + inputFormatters; float-safe (server stores JSON number) |
| Boolean editor | boolean | AppSwitch row; no Save needed if instant-save toggle (see 06 §S2 decision) |
| JSON editor | object/array/null | AppJsonEditor (multi-line mono + live parse check); null treated as JSON null |
| Unknown | anything else | fall back to JSON editor |
Type inference: from the runtime JSON type of the fetched value (setting.schema.ts:21-22);
string-y numbers stay strings (no coercion — server is byte-transparent).
4. New Setting (FAB flow) — sheet
| Field | Detail |
|---|---|
| Purpose | Create an arbitrary key |
| Form | key (required, non-empty, trim), group dropdown (default GENERAL), value typed editor (JSON box) |
| Submit | PUT /settings upsert — idempotent; on E11000/500 (recreate-after-delete, OQ-5) → error banner |
| Exit | success → row appears (server-sorted position) |
5. Delete Confirm (dialog/sheet)
Confirm (destructive) → DELETE /settings/:key → snackbar; 404 → treat as removed.
Soft delete server-side (base.repository.ts:68-74) — no undo (recreate broken, OQ-5).
6. History — NOT AVAILABLE
| Item | Status |
|---|---|
| Per-setting change history / audit trail in the UI | (planned) — no settings history endpoint exists (audit module logs events generically; settings emits none). OQ-7. |
7. Cross-surface references (not module-owned)
| Screen | Owner | Route |
|---|---|---|
| Org embedded settings tabs | Organizations | /organization (S4) |
| Feature flags list | Organizations (uses Feature Flags module API) | /organization flags tab |
| Roles/members | RBAC | /settings/roles, /settings/members |
| Security hub | Auth | /settings/security |
Shared components used
AppSkeleton, AppEmptyState, AppErrorState, AppOfflineBanner, AppSnackbar,
AppSearchBar, AppChips, AppDropdown, AppTextField, AppSwitch, AppCard,
AppListTile, AppMenu, AppDialog, AppBottomSheet, AppFAB, AppBadge.
Module-specific: SettingRow, AppTypedValueEditor, AppJsonEditor, SettingGroupChips
(07_Component_Library.md).
Analytics events (proposed)
settings.list.{view,group_tap,search}, settings.detail.view,
settings.edit.{save,error}, settings.create.{submit,error},
settings.bulk.{save,n,failed_m_of_n}, settings.delete.{confirm,completed}.
Keyboard / landscape / tablet / desktop
- Phone: single pane; landscape scrolls; keyboard avoidance on editors.
- Tablet/desktop: master-detail;
Ctrl+Fsearch; hover row highlight;Entersaves editor.
06 — Screen Specifications (Settings Module)
- S1 — Settings List
/settings - S2 — Setting Detail / Typed Editors (module core)
- S3 — New Setting (FAB sheet)
- S4 — Delete flow
- S5 — Cross-link banner (list top, dismissible) — context clarity
- Shared micro-interactions & motion (all screens)
Production-grade specs for each Settings screen: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens:
Sp= 00-shared/02 §3,Ty= typography §2,El= elevation §5,R= radius §4,Mo= motion §6. All components reference 00-shared/03.
S1 — Settings List /settings
Chrome: standard shell AppBar (Icons.settings, headlineMedium "Settings"); horizontal
padding 16 (phone) / 24 (tablet+); content in ListView.builder (never Column of cards —
keys can number in the hundreds).
Layout (phone):
AppBar: "Settings" headlineMedium
AppSearchBar (client-side) h 56, debounce 300 ms
Group chips (horizontal scroll) FilterChip per enum value + "All"
Group section header (first group) titleMedium "Attendance (12)"
SettingRow (key / value / type chip) AppCard flat, 56–72 h
SettingRow ...
[FAB extended: "New setting"] Icons.add
Layout (desktop ≥840 dp): master-detail — left pane list (max width 480), right pane =
detail S2 for the selected row; selection persists in SettingsCubit.
States:
- loading →
AppSkeleton(list)(≤200 ms target). - success+empty →
AppEmptyStateIcons.tune"No settings yet — add your first key." - group-empty → inline
bodySmall"Nothing in {group} yet." - error →
AppErrorState(code)(401 → refresh flow; 5xx → generic + requestId + Retry). - offline →
AppOfflineBanner+ last-good rows; editors disabled with tooltip. - permission → route guard to
/settings/403(nosettings.read).
Group chips: server-filtered (GET /settings?group=) — tapping a chip triggers a
server refetch (not client filter), because the contract filters server-side
(settings.controller.ts:26-28). The search box filters the current fetch client-side
(no q param exists — 00-shared/07 §5 applies only where controllers support it).
Row (SettingRow):
| Element | Spec |
|---|---|
| Leading | group icon map (school/event_available/grading/notifications/palette/settings) |
| Title | key in mono titleMedium (FontFeature.tabularFigures() for numeric keys) |
| Subtitle | value preview, truncated 1 line: strings quoted, numbers plain, booleans as true/false, objects {…} + count, arrays [… n] |
| Trailing | type chip (AppBadge: STR / NUM / BOOL / JSON), AppMenu (Edit, Duplicate key, Delete) |
| Tap | → detail pane/page |
| Dirty marker | when edited in place: left primary indicator dot + row tinted primaryContainer |
Batch mode: long-press enters selection; AppBottomSheet fixed bottom bar "Save all (N)" —
only dirty keys serialized (03 §3). Per-row saved state clears the dot (Mo.m-fast fade).
Motion: list entrance m-entrance stagger 40 ms; chip indicator slide m-base;
FAB hide on scroll-down, show on scroll-up (00-shared/03 D).
A11y: search Semantics(liveRegion) for result count ("12 of 48"); chips announce
selected state; rows are single Semantics(button) with key label; dirty state announced
via Semantics(label: "unsaved").
Dark/light: token-only; dirty tint primaryContainer on dark = elevated contrast.
S2 — Setting Detail / Typed Editors (module core)
Chrome: detail pane (desktop) or pushed page (phone). Header: key (mono titleLarge),
group dropdown, isEncrypted badge ((planned)), meta footer
(bodySmall: "created {date} · v{version}" — base.schema.ts:30-34; createdBy/updatedBy
when present).
Editor by value type (client inference, 05 §3)
| Type | Editor | Save behavior | Validation |
|---|---|---|---|
string | AppTextField, single line, mono for likely IDs/URLs | explicit Save button (or Enter) | non-empty unless empty allowed by key; no server rule (update-setting.dto.ts:5-10) |
number | AppTextField keyboard numberWithOptions(decimal: true), formatter ^\d*\.?\d*$ | explicit Save | parseable float/int; saved as JSON number (no string coercion) |
boolean | AppSwitch row | instant save on toggle (safe mutation — 00-shared/06 §3.5); server-confirm then reflect; rollback + snackbar on error | n/a |
object / array / null | AppJsonEditor (mono multi-line, min 6 rows, grow) | explicit Save (disabled while invalid) | live JSON parse; save blocked on parse error with inline message |
| unknown type | fall back to JSON editor | same as JSON | — |
Save semantics (from source):
- Full replacement of
value:$set: {value}(setting.repository.ts:37) — never merge. groupis sent with the save and persisted if present (setting.repository.ts:37); omitting it keeps the existing group (onlyvalue+groupare ever written — the DTO'slabel/descriptionare dropped bysettings.service.ts:25).- Upsert idempotent (
{upsert:true, new:true}); server returns the saved doc → detail reconciles to server truth. - No optimistic write for explicit-save editors; boolean toggle is optimistic with
rollback (
00-shared/06 §3.5).
Validation errors (client-first, server shadows):
| Field | Client rule | Server | UI |
|---|---|---|---|
key (create/rename) | non-empty trimmed string | no decorators — missing key → Mongoose required → 500 (OQ-3) | required field error |
group | ∈ enum | @IsEnum(SettingGroup) (400 VALIDATION_ERROR) | dropdown restricts options |
value | any JSON | value: unknown — any JSON accepted | typed editors |
label / description | — | @IsString() optional | hidden (unused by service) |
Save button: loading spinner replaces label (Mo.m-base); disabled while pending
(anti-double-submit 00-shared/08 §6); on success AppSnackbar "Saved" (<3 s) +
HapticFeedback.lightImpact().
Error surfaces: 400 → inline field errors (from details[], http-exception.filter.ts:103-108);
5xx → snackbar generic + requestId, form kept (retry safe — upsert idempotent).
S3 — New Setting (FAB sheet)
AppBottomSheet (full height on phone, centered card ≤520 on desktop):
keyfield (required, autofocus,monohint "e.g. attendance.lateThreshold").- group
AppDropdown(enum, default GENERAL —setting.schema.ts:24-25). - value: type unknown at creation → JSON editor (accepts string/number/bool/object); after first save the row gains its concrete type chip.
- CTA "Create" →
PUT /settings; success → snackbar + list refetch; 500 duplicate-key (recreate-after-delete, OQ-5) → banner explaining the key is soft-deleted.
S4 — Delete flow
AppDialog (destructive): title "Delete {key}?"; body explains soft-delete + that the key
cannot be re-created today (OQ-5 warning). Confirm → DELETE /settings/:key
(settings.controller.ts:49-53); loading on confirm; success → row fade-out (Mo.m-base) +
snackbar; 404 → row removed silently.
S5 — Cross-link banner (list top, dismissible) — context clarity
AppBanner(info): "Attendance rules, theme and working days also live under Organization"
→ /organization (embedded settings surface, organization.schema.ts:96-112). One-time
dismiss persisted in shared_preferences. Rationale: dual-surface confusion is a known
risk (design-docs/organizations/09 §8 — "Which settings apply?").
Shared micro-interactions & motion (all screens)
- Route transitions
Mo.m-base; AppBarEl-1; cardsEl-e1; sheets/dialogse-4/e-5. - Snackbar durations: success < 3 s, error ≥ 4 s (
00-shared/03 A). - Keyboard:
Entersaves editors,Esccloses sheet/confirm,Ctrl+Fsearch (desktop),Ctrl/Cmd+Entersubmits JSON editor (00-shared/08 §2). - Safe areas: bottom
Save allbar respects nav bar height on phone. - Reduced motion: no stagger, no shake — fades/instants only (
00-shared/08 §4).
07 — Component Library (Settings Module)
- A. Setting row & list artifacts
- B. Typed value editors
- C. Save & feedback
- D. Cross-surface teaser
- E. Flutter widget map (module)
- F. Golden tests (base 00-shared/03 G)
Module-specific components built from shared
App*primitives (00-shared/03). Props, states, animation, a11y, tokens, Flutter mapping. Reuse over new — only components that don't exist in the shared library are defined here.
A. Setting row & list artifacts
AppSettingRow
| Field | Spec |
|---|---|
| Purpose | One setting in the list (data-driven — renders whatever GET /settings returns) |
| Props | key, value (any), group, dirty, editing, onTap, onMenu, saving |
| Type chip | derived from runtime value: str / num / bool / json (AppBadge) |
| Rendering | AppCard flat (e-0) + AppListTile-style: leading group icon, title mono key, subtitle truncated value preview, trailing type chip + AppMenu |
| Dirty | primary left indicator dot + primaryContainer tint, announced via semantics "unsaved" |
| Saving | trailing CircularProgressIndicator 16 dp replaces chip while PUT in flight |
| a11y | single Semantics(button, label: "<key>, value <preview>, <type>") |
| Flutter | Card + InkWell + Row; icons from group map |
AppGroupChips
- Filter chips for the 6 enum groups + "All" (
setting.schema.ts:7-14); server-refetch per chip (GET /settings?group=,settings.controller.ts:26-28). - Selected chip
secondaryContainer; horizontal scroll on phone (SingleChildScrollView). - a11y:
Semantics(toggled:)per chip; group order = enum declaration order.
AppSettingsSectionHeader
AppSectionHeadervariant: group name + count from the fetched array ("Attendance (12)"),titleMedium, sticky on desktop pane.
B. Typed value editors
AppTypedValueEditor (dispatcher)
- Props:
value(any),onChanged,onSave,saving,compact. - Dispatches by runtime type →
AppTextField(string), numeric field (number),AppSwitchrow (boolean),AppJsonEditor(object/array/unknown). Compact mode = single line for list rows; expanded = detail pane.
AppJsonEditor
| Field | Spec |
|---|---|
| Purpose | Edit object/array/null values with live parse feedback (server stores raw JSON — setting.schema.ts:21-22) |
| Props | value, onChanged, onSave, minLines (6), expands |
| Rendering | multi-line TextFormField mono 14, no syntax highlight (keep dependency-free), grow with content |
| Validation | live jsonDecode; valid → primary check icon + Save enabled; invalid → error outline + inline "Not valid JSON" (bodySmall) + Save disabled |
| Save | Ctrl/Cmd+Enter (desktop) / Save button; pretty-prints (JsonEncoder.withIndent(' ')) on success |
| a11y | Semantics(error) on parse failure, announced live; label "JSON value" |
| Flutter | TextFormField(maxLines: null, keyboardType: TextInputType.multiline) |
AppBoolRow
AppSwitchin aListTile; instant-save on toggle (optimistic, rollback on error — 00-shared/06 §3.5);savingdisables switch; subtitle shows last save status.
C. Save & feedback
AppSaveBar (batch)
- Bottom bar in selection mode: "Save all (N)"
FilledButtonfull width + "Cancel"TextButton; counts only dirty rows; on save → per-row status (✓ saved / ✗ failed, tap row retries). - a11y: bar announced; result via snackbar "Saved 4 of 5 settings".
AppSettingNotFound
AppEmptyStatevariant forGET /settings/:key404 (settings.service.ts:20): icontune, "Setting not found", back button.
D. Cross-surface teaser
AppOrgSettingsLink (banner)
- Dismissible
AppBanner(info)pointing at/organization(embedded settings surface); persisted dismissal keyorg_settings_link_dismissed(shared_preferences).
E. Flutter widget map (module)
| Component | Flutter widgets |
|---|---|
| AppSettingRow | Card + InkWell + ListTile + MenuAnchor |
| AppGroupChips | FilterChip row in SingleChildScrollView |
| AppSettingsSectionHeader | Padding + Row + Text |
| AppTypedValueEditor | switch on runtime type → fields below |
| AppJsonEditor | TextFormField + jsonDecode validator |
| AppBoolRow | SwitchListTile |
| AppSaveBar | Material + SafeArea + FilledButton |
| AppSettingNotFound | AppEmptyState |
| AppOrgSettingsLink | MaterialBanner |
F. Golden tests (base 00-shared/03 G)
Every module component golden at phone/tablet/desktop + dark; AppJsonEditor valid/invalid
states; AppSettingRow clean/dirty/saving; AppSaveBar 0/1/N dirty.
08 — Form Specifications (Settings Module)
- 1. Upsert Form —
PUT /settings(update-setting.dto.ts) - 2. Value-type-specific editing (core form behavior)
- 3. New Setting Form (FAB sheet)
- 4. Batch Save —
PUT /settings/bulk - 5. Delete — no form
- Form-level rules (all)
- Client-side error priority
Every field of every Settings form/editor, field-by-field. Validation mirrors the class-validator decorators exactly from
src/modules/settings/dto/update-setting.dto.ts; where the DTO has no decorator (key/value), the server accepts anything and a missing required field surfaces as a Mongoose validation error → 500 (OQ-3). Client validates inline first; the server remains the authority forgroupenum.
1. Upsert Form — PUT /settings (update-setting.dto.ts)
| # | Field | Label | Type / Editor | Required | Validation (server) | Client UX |
|---|---|---|---|---|---|---|
| 1 | key | Key | text, mono | yes (TS !) | no decorators (update-setting.dto.ts:6-7) — missing/undefined → Mongoose required error → 500 (OQ-3) | trim; non-empty; pattern hint ^[a-z0-9._-]+$ (convention, not enforced); error: "Enter a key" |
| 2 | value | Value | typed editor by runtime type (string / number / boolean / JSON) | yes (TS !) | no decorators (update-setting.dto.ts:9-10) — any JSON accepted; stored raw (setting.schema.ts:21-22) | type-specific (see §2); never coerced |
| 3 | group | Group | AppDropdown (enum) | no | @IsEnum(SettingGroup) optional (update-setting.dto.ts:12-15) → 400 VALIDATION_ERROR with details | default GENERAL (setting.schema.ts:24-25); dropdown restricts to 6 values |
| 4 | label | Label | text | no | @IsString() optional (update-setting.dto.ts:17-20) | hidden — never persisted (settings.service.ts:25 drops it) |
| 5 | description | Description | text | no | @IsString() optional (update-setting.dto.ts:22-25) | hidden — never persisted |
Server 400 details mapping: invalid group → details: [{message: "group must be one of the following values: academic, attendance, grading, notification, theme, general"}] (http-exception.filter.ts:103-108).
Submit behavior: save → PUT /settings → 200 returns the saved doc
(setting.repository.ts:35-39, new: true); form reconciles to server truth. Retry-safe
(idempotent upsert) — a network retry never duplicates.
2. Value-type-specific editing (core form behavior)
| Type | Editor | Formatting / formatters | Local validation | Server |
|---|---|---|---|---|
string | single-line AppTextField | none; mono for key-like values | non-empty (unless the key was created with an empty string — allowed) | stored as-is |
number | numeric AppTextField | FilteringTextInputFormatter.allow(RegExp(r'[0-9.\-]')) | double.tryParse != null | stored as JSON number (value: unknown → $set {value} raw, setting.repository.ts:37) |
boolean | AppSwitch (SwitchListTile) | — | — | stored as JSON true/false; instant save on toggle |
object / array / null | AppJsonEditor (mono, multi-line) | pretty-print on save (JsonEncoder.withIndent(' ')) | jsonDecode must succeed | stored as raw JSON object |
| unknown | JSON editor fallback | same | same | — |
Rule: no type coercion on save — a numeric-looking string stays a string; the client never guesses. Type chips in the list re-derive from the last server response.
3. New Setting Form (FAB sheet)
Fields: key (1), group (3), value as JSON editor (type unknown at creation).
On success → row appears in its group; group move after creation = edit the group dropdown
and save again (upsert persists group when present, setting.repository.ts:37).
4. Batch Save — PUT /settings/bulk
| Aspect | Contract |
|---|---|
| Request | JSON array of UpdateSettingDto (settings.controller.ts:43-47) |
| Server | sequential upsert loop, all results returned (settings.service.ts:28-34) — not transactional (OQ-4) |
| Client | only dirty keys serialized; each item = the full {key, value, group} (never partial {value} — an omitted group on an existing row is fine, but sending the known group is the stable form) |
| Failure | "Saved N of M" + per-row retry; re-send the full dirty set (idempotent) |
| Duplicate keys in one batch | last item wins (loop order) — client dedupes by key before sending |
5. Delete — no form
DELETE /settings/:key (settings.controller.ts:49-53) — confirm dialog only; no body.
404 → treat as removed. Soft-delete consequence: re-creating the key fails with E11000 → 500
(OQ-5); dialog copy warns.
Form-level rules (all)
- Double-submit: Save disabled while pending (00-shared/08 §6).
- Optimistic: explicit-save editors are server-confirm; boolean toggle is the only optimistic write (rollback on error — 00-shared/06 §3.5).
- Undo: none for delete (soft-delete not reversible through the API — OQ-5); value edits undo = re-edit (last-write-wins).
- Abandonment: dirty explicit editors prompt "Discard changes?"; batch mode Cancel restores last-server values.
- Keyboard:
Entersaves single-line editors,Ctrl/Cmd+Entersaves JSON editor,Tabfield order key → group → value;Esccancels. - Error copy: business 4xx
messageallowed; codes drive the rest (00-shared/07 §11).
Client-side error priority
- 400 VALIDATION → field (group enum; JSON parse is client-side pre-submit).
- 401 UNAUTHENTICATED → silent refresh; fail → session expiry.
- 404 RESOURCE_NOT_FOUND (detail/delete) → treat as removed.
- 500 INTERNAL_SERVER_ERROR → generic + requestId; form kept (retry safe).
- 429 RATE_LIMITED → countdown (global
apitier, 00-shared/07 §4).
09 — User Behaviour (Settings Module)
- 1. Expected behaviour (server-contract-driven)
- 2. Common mistakes & countermeasures
- 3. Power & new users
- 4. Mobile & a11y users
- 5. Interrupted sessions & retries
- 6. Abandonment
- 7. Frustration points → UX treatments (summary)
- 8. Cross-device & conflict
- 9. Adoption path (holistic)
Expected behaviour (derived from server semantics + shared interaction rules), the common mistakes users make with these screens, and the UX countermeasures. Purpose: hand the UI designer a playbook of what the user does so the flow never surprises.
1. Expected behaviour (server-contract-driven)
- List is always server-sorted:
{group: 1, key: 1}(settings.service.ts:11) — the UI must never re-sort into a different order than the API returns; grouping is presentational. - Group chip = server filter: tapping "Attendance" refetches
(
settings.controller.ts:26-28) — users expect the list to shrink server-authoritatively. - Save = full value replacement (
setting.repository.ts:37): editing an object and forgetting a key inside it loses that nested key. UI: JSON editor starts from the current serialized value; never from an empty box. - Boolean toggles save instantly; text/number/JSON need an explicit save — users learn the pattern from the chip (BOOL rows show no Save button).
- Delete is final-looking: soft delete means the row disappears but the key is burned (recreate → 500, OQ-5) — the confirm dialog must say "cannot be re-created".
- Permissions are client-only today (OQ-2): a user without
settings.*sees nothing (guard-hidden), but the server would serve them if called directly.
2. Common mistakes & countermeasures
| Mistake | Related | UX countermeasure |
|---|---|---|
| Editing an object and losing nested fields | full-replace $set {value} (setting.repository.ts:37) | JSON editor always prefilled from server value; never blank; "replace whole value" helper text |
| Not knowing a value's type until it breaks | value: unknown (update-setting.dto.ts:10) | type chip on every row; typed editors per value (06 §S2) |
| Batch save with unrelated edits mixed in | sequential loop (settings.service.ts:28-34) | dirty-dot per row; "Save all (N)" counts only dirty; per-row retry on partial failure |
| Typing "5" expecting number, saved as string | no coercion server-side | number editor for num values; string stays string — chip shows type so it's discoverable |
| Searching for a group word in search box | no server q param | group chips do the filtering; search box says "search keys" in hint |
| Deleting then recreating a key | unique index {tenantId,key} (setting.schema.ts:38) + soft delete | confirm copy warns; error banner on E11000 explains soft-delete state (OQ-5) |
| Editing org settings in the wrong surface | dual surface (organization.schema.ts:96-112 vs settings collection) | AppOrgSettingsLink banner → /organization (06 §S5) |
3. Power & new users
- Power user (admin): keyboard-first on desktop —
Ctrl+Fsearch,Entersave,Nnew setting; batch-selects a group and edits in one pass; uses group chips to narrow. - New user (first-time admin): lands on the list; the
AppOrgSettingsLinkbanner is the first thing they see; empty state teaches "add your first key" with the FAB.
4. Mobile & a11y users
- One-thumb phone: FAB reachable; row menus on tap (not long-press-first); editors keyboard-avoidance.
- TalkBack/VoiceOver: rows announced with key + type + value; dirty state announced; JSON parse errors announced on focus.
- Dynamic type 200%: key
monowraps (ellipsis only on preview subtitle — never on the key itself). - Reduced motion: no stagger/shake; fades only.
5. Interrupted sessions & retries
- Suspend/resume: list cache re-shown (stale-while-revalidate); editors restore last server value (never partial draft on relaunch — drafts are in-memory only).
- Mid-save interruption (app kill): upsert is idempotent — on next open the value is whatever the server has; the row reconciles from a refetch.
- Retry after 5xx: form kept, "Retry" snackbar action re-submits the same payload.
- 429: countdown; no auto-retry (00-shared/07 §4).
6. Abandonment
- Where people quit and what to do:
- Dirty explicit editor + back → "Discard changes?" dialog.
- Batch mode + back → Cancel restores last-server values (dirty dots clear).
- New-setting sheet half-filled → close = discard (no draft).
- No server drafts exist — everything in-memory is forfeit on close; the list itself is safe to re-fetch.
7. Frustration points → UX treatments (summary)
| Frustration | Treatment |
|---|---|
| "Which surface owns attendance?" | cross-link banner (S5) + group copy |
| "My JSON got mangled" | pretty-print on save; parse-error blocked before send; never blank prefill |
| "It saved half my batch" | per-row status in AppSaveBar, retry per row (03 §3) |
| "Key disappeared after delete" | warning copy + E11000 banner (OQ-5) |
| "I can't find the key" | client search + group chips + server-sorted groups |
8. Cross-device & conflict
- Two admins editing the same key: last write wins (no optimistic-lock check on the
upsert path —
setting.repository.ts:35-39;versionexists on the schema (base.schema.ts:30-31) but the upsert never compares it, OQ-9). UI: refetch on screen focus shows the latest value; no merge, no conflict dialog (config is last-write-wins by design). - Concurrent batch saves on different keys: both succeed (per-key docs).
9. Adoption path (holistic)
P0: list + group filter + single save + delete. P1: batch save, JSON editor polish, client
search. P2 (planned): settings history (OQ-7), encrypted values (OQ-6), settings registry
with defaults, permission enforcement wiring (OQ-2), COACHING group
(IMPLEMENTATION_PLAN.md:773).
10 — Interaction Specification (Settings Module)
- 1. Phone / touch
- 2. Keyboard / desktop / mouse
- 3. Haptics (mobile)
- 4. Motion language (module extension of 00-shared/08 §4)
- 5. Micro-interactions catalogue
- 6. Frustration controls (shared 08 §6 + module)
- 7. Special interactions
- 8. Validation of interaction to server semantics
Module-specific interactions precise for the settings list, typed editors and batch save, layered on shared 00-shared/08. Covers every input modality on phone/tablet/desktop.
1. Phone / touch
| Gesture | Behaviour | Where |
|---|---|---|
| Tap | open row, open editor, toggle switch, chip filter, FAB new | all |
| Tap row menu | AppMenu (Edit value, Duplicate key, Delete) | rows |
| Long-press | enter batch selection mode (multi-select) | rows |
| Double-tap | none (no media) | — |
| Swipe left | none — destructive swipe disabled (delete needs confirm) | — |
| Pull down | RefreshIndicator → refetch current group (bypasses cache) | list |
| Pinch/drag | none | — |
| Hardware back | close editor (discard prompt if dirty) → list; on root → exit | all |
| Edge swipe | shell drawer (authed) | shell |
2. Keyboard / desktop / mouse
| Input | Behaviour |
|---|---|
| Tab | search → chips → rows → FAB (desktop pane: then detail fields key→group→value) |
| Enter | save active editor (single-line); submit Create sheet |
| Esc | close editor/sheet/dialog → focus returns to trigger; cancels selection mode |
| Ctrl/Cmd+F | focus client search (00-shared/08 §2) |
| Ctrl/Cmd+Enter | save JSON editor |
+ / N | new setting (list screen, desktop) |
| Arrows | navigate group chips (focused chip), dropdown options |
| Right-click | context menu (same as long-press: row menu, multi-select toggle) |
| Hover | row tint m-fast, tooltip after 500 ms (key copy button, type chip) |
| Focus | visible primary ring on all controls; logical order |
3. Haptics (mobile)
| Event | Haptic | Ref |
|---|---|---|
| Save success | lightImpact | — |
| Save/batch failure | mediumImpact | — |
| Boolean toggle applied | selectionClick | — |
| Delete confirm | heavyImpact | — |
| Rule | disabled when system haptics off; none on desktop | 08 §3 |
4. Motion language (module extension of 00-shared/08 §4)
| Transition | Duration/curve | Used by |
|---|---|---|
| Route push (list→detail) | m-base slide | phone |
| Detail pane swap (master-detail) | m-base fade | tablet/desktop |
| Row saved → dirty dot clears | m-fast fade | list |
| Row removed (delete) | m-base fade-out | list |
| Batch bar appears | m-entrance (300 ms, easeOutBack) | list |
| JSON editor error | m-fast outline color swap + AnimatedSwitcher message (no shake) | editors |
| FAB hide/show on scroll | m-entrance/fade, scroll-dir driven | list |
Reduced-motion: fades + instant only; no stagger, no entrance bounce (00-shared/08 §4).
5. Micro-interactions catalogue
| Interaction | Detail |
|---|---|
| Save pressed | button loading spinner swap m-base; double-tap locked; on success check + snackbar |
| Boolean toggle | switch animates m-fast, optimistic; failure → rollback + error snackbar |
| Group chip tap | chip indicator slide m-base; list refetches (server filter) |
| Dirty edit | row dot primary pops in m-fast; Save bar count updates |
| Batch save completes | per-row ✓/✗ (m-fast); snackbar "Saved N of M" |
| Type chip tap | tooltip "value type: number" (desktop) / no-op (phone) |
| Key copy (detail) | Clipboard.setData + snackbar "Copied" + selectionClick |
6. Frustration controls (shared 08 §6 + module)
- All presses respond < 100 ms with
m-fastripple. - Every network action shows pending state (row spinner, button spinner).
- Save buttons disabled while pending — no double-submit.
- 429 → never auto-retry; countdown surfaced.
- Errors persist until corrected/dismissed (banner or inline field error).
7. Special interactions
- Batch retry: tap a failed row's ✓/✗ chip → re-sends the full dirty set for that key (idempotent upsert, 08 §4).
- JSON paste: editor accepts multi-line paste; parse check runs debounced (300 ms); invalid → Save disabled until fixed.
- Deep links
(forward-looking):studylyon://settings/group/:grouppre-selects chip;studylyon://settings/:keyopens detail directly.
8. Validation of interaction to server semantics
- "Save all" only ever sends dirty keys, each as full
{key, value, group}— matchesPUT /settings/bulk(settings.controller.ts:43-47). - No optimistic saves for explicit editors (server truth returned in
data,setting.repository.ts:39). - Delete never offers undo (soft-delete is irreversible through the API — OQ-5).
- Group chips always refetch server-side — never client-filter the full list, because the
API contract filters on the server (
settings.controller.ts:26-28).
11 — Design System Mapping (Settings Module)
- 1. Theme recipes (module)
- 2. Widget → token
- 3. Typography mapping
- 4. Iconography
- 5. Motion tokens (values 00-shared/02 §6)
- 6. Contrast & dark parity
- 7. Breakpoint behaviour
- 8. Cross-surface brand override
Settings module → Material 3 / Flutter / token mapping, extending 00-shared/04. Every color, type, spacing, elevation, radius, icon, motion reference traces to 00-shared/02_Design_Tokens.md.
1. Theme recipes (module)
| Screens | Theme | Exception |
|---|---|---|
| Settings list (hub) | standard shell (surface) | dirty rows primaryContainer tint; type chips surfaceVariant |
| Setting detail/editors | standard shell | JSON editor surfaceVariant fill; error error outline |
| Batch mode | standard shell | bottom bar surfaceContainerHigh, e-3 |
| Cross-link banner | AppBanner(info) | primaryContainer |
| Empty/not-found states | surface | AppEmptyState tokens |
2. Widget → token
| Component | M3 / Flutter | Tokens | Elev | Radius |
|---|---|---|---|---|
| Setting row card | AppCard flat | surfaceContainerLow | e-0 | r-md |
| Dirty row | AppCard tonal | primaryContainer / onPrimaryContainer | e-0 | r-md |
| Type chip | AppBadge | surfaceVariant / onSurfaceVariant | — | r-sm |
| Group chips | FilterChip | selected secondaryContainer, unselected surfaceVariant | — | r-sm |
| Key text | Text | mono, titleMedium | — | — |
| Value preview | Text | bodyMedium onSurfaceVariant, 1-line ellipsis | — | — |
| Editors (string/number) | AppTextField | fill surfaceVariant, border outline, focus primary, error error | — | r-md |
| JSON editor | TextFormField mono | same; valid primary check, invalid error | — | r-md |
| Boolean row | SwitchListTile | switch primary | — | — |
| Save button | FilledButton | primary/onPrimary | — | r-md |
| Batch bar | Material + FilledButton | surfaceContainerHigh, e-3 | e-3 | — |
| Delete confirm | AppDialog | surfaceContainerHigh, destructive error | e-4 | r-lg |
| FAB (new setting) | FloatingActionButton.extended | primaryContainer | e-3 | r-full |
| Cross-link banner | MaterialBanner | primaryContainer | e-1 | — |
3. Typography mapping
| Token | Screen element |
|---|---|
headlineMedium | "Settings" screen title |
titleLarge | detail key header, dialog titles |
titleMedium | section headers ("Attendance (12)"), row keys (mono) |
bodyMedium | value preview, labels, helper text |
bodySmall | meta footer (created/version), "Nothing in this group", JSON error line |
labelLarge | buttons, group chip labels |
mono | keys, JSON editor, numeric values |
Numerals (versions, counts) use FontFeature.tabularFigures() (00-shared/02 §2).
4. Iconography
Material Symbols outlined; module map: settings, tune, school (academic),
event_available (attendance), grading, notifications (notification), palette (theme),
manage_search (general), add, content_copy, delete, check_circle, error,
chevron_right, data_object (JSON).
Group → icon map (leading icon per group):
| Group | Icon |
|---|---|
| academic | school |
| attendance | event_available |
| grading | grading |
| notification | notifications |
| theme | palette |
| general | manage_search |
5. Motion tokens (values 00-shared/02 §6)
| Purpose | Token | Applied to |
|---|---|---|
| Save → result | m-base | snackbar/check swap |
| Dirty dot pop / clear | m-fast | row indicator |
| Row delete exit | m-base | fade-out |
| Batch bar entrance | m-entrance | bottom bar |
| Chip indicator | m-base | group chips |
| Editor error | m-fast | outline/message swap (no shake) |
| List entrance | m-entrance stagger 40 ms | first paint |
Rules: reduced-motion substitutes fades/instants (00-shared/08 §4).
6. Contrast & dark parity
- All text ≥ 4.5:1; interactive chrome ≥ 3:1 (00-shared/09 §2).
- Dark: dirty tint
primaryContainerdark variant (TBD by designer, 02 §1.4); JSON editor fillsurfaceVariant; type chips readable (onSurfaceVariant). - No sole-color status: dirty = dot + "unsaved" semantics; saved = snackbar + check; type = chip label (never color-only, 00-shared/09 §9).
7. Breakpoint behaviour
| Class | List | Detail |
|---|---|---|
| phone <600 | single pane, chips scroll horizontally | pushed page |
| 600–839 | list full width; detail sheet-like push | push |
| 840–1199 | master-detail two-pane (list ≤ 480) | right pane |
| ≥1200 | master-detail; hover rings; keyboard shortcuts | right pane |
8. Cross-surface brand override
Org branding.primaryColor/settings.theme.primaryColor
(organization.schema.ts:108-120) re-seeds the ColorScheme at runtime
(00-shared/04 §7.5) — the Settings hub renders under the same overridden theme; a
theme group value change takes effect on next theme reload (proposed wiring).
12 — API Mapping (Settings Module)
- 0. Module-wide request envelope & client policy
- E1 — List settings
- E2 — Get setting by key
- E3 — Upsert setting
- E4 — Bulk update
- E5 — Delete setting
- Related surfaces (context, not module-owned)
- Loading / streaming / realtime
- Client-side error mapping table (module)
- Optimistic / undo
Exact wire contract for every screen → endpoint. Base
/api/v1; envelope per 00-shared/07 andresponse-envelope.interceptor.ts/http-exception.filter.ts. All endpoints fromsrc/modules/settings/settings.controller.ts; business rules fromsettings.service.ts+setting.repository.ts. Global guard chain:RateLimitGuard→JwtAuthGuard→RbacGuard(app.module.ts:129-131); the controller additionally declares@UseGuards(JwtAuthGuard)(settings.controller.ts:19) and carries no@Permissionsmetadata (OQ-2 — the global RbacGuard passes because no permission is required,rbac.guard.ts:29).
0. Module-wide request envelope & client policy
| Aspect | Contract |
|---|---|
| Base | https://api.<domain>/api/v1 |
| Headers | Authorization: Bearer <accessToken>; x-request-id client-generated; Content-Type: application/json |
| Success | {success:true, message:"OK", data, meta?, timestamp, requestId} — message is always "OK" (response-envelope.interceptor.ts:48-53) |
| Error | {success:false, message, error:{code, details?}, timestamp, requestId} — codes per http-exception.filter.ts:27-35 |
| Pagination | NOT paginated. findAll returns a bare array (settings.service.ts:10-12); the interceptor only emits meta when the payload is {data, meta} (response-envelope.interceptor.ts:25-32) → meta is omitted for every settings endpoint. Client renders the full array (no infinite scroll). |
| Tenancy | tenantId from JWT claim via TenantContextService (setting.repository.ts:16,34); never in body. Platform admin bypasses the tenant scope (base.repository.ts:21-23). |
| Caching | none server-side in the settings path; client stale-while-revalidate |
| Offline | reads from last-good cache + banner; writes blocked (no offline queue) |
| Retry | backoff on 5xx/network; no auto-retry on 429 |
E1 — List settings
| Endpoint | GET /settings (settings.controller.ts:24-29) |
| Query | ?group= optional, ∈ SettingGroup enum (settings.controller.ts:26; setting.schema.ts:7-14) |
| Success | 200 data: Setting[] — sorted group:1, key:1 (settings.service.ts:11); array, no meta |
| Auth | JWT (all roles — permission not enforced, OQ-2) |
| Errors | 400 if group not in enum (VALIDATION_ERROR); 401; 429; 5xx |
| Screen | S1 list / group chips (05 §1, 06 §S1) |
Setting doc shape (setting.schema.ts:17-35 + base.schema.ts:10-34):
{
"_id": "...", "tenantId": "...",
"key": "attendance.gracePeriod", "value": 10,
"group": "attendance",
"label": null, "description": null, "isEncrypted": false,
"createdBy": null, "updatedBy": null,
"isDeleted": false, "deletedAt": null, "deletedBy": null,
"version": 0, "createdAt": "...", "updatedAt": "..."
}
(label/description are schema fields but never written by the service —
settings.service.ts:25; isEncrypted is never set — OQ-6.)
E2 — Get setting by key
| Endpoint | GET /settings/:key (settings.controller.ts:31-35) |
| Success | 200 data: Setting |
| 404 | RESOURCE_NOT_FOUND, message Setting "«key»" not found. (settings.service.ts:20) |
| Screen | S2 detail (06 §S2) — client treats 404 as "not found" empty state |
E3 — Upsert setting
| Endpoint | PUT /settings (settings.controller.ts:37-41) |
| Request | UpdateSettingDto (update-setting.dto.ts:5-26): key (req, unvalidated), value (req, unvalidated — any JSON), group? (@IsEnum), label?/description? (@IsString — dropped by service) |
| Success | 200 data: Setting (the saved doc, new: true — setting.repository.ts:39) |
| Semantics | Idempotent upsert: findOneAndUpdate({tenantId,key,isDeleted:false}, {$set:{value, ...(group?{group}:{})}}, {upsert:true, new:true}) (setting.repository.ts:35-39) — value replaced wholesale, never merged |
| Errors | 400 invalid group (details per field); 500 for missing key/value (no DTO decorators → Mongoose required error, OQ-3); 500 E11000 on recreate-after-delete (OQ-5); 401; 429 |
| Screen | S2 save / S3 create (06 §S2-S3) |
E4 — Bulk update
| Endpoint | PUT /settings/bulk (settings.controller.ts:43-47) |
| Request | UpdateSettingDto[] (JSON array) |
| Success | 200 data: Setting[] — one doc per input, in input order (settings.service.ts:28-34) |
| Semantics | Sequential per-key upserts — no transaction; a mid-batch failure returns 5xx with earlier keys already persisted (OQ-4) |
| Client | send only dirty keys; per-item retry re-sends full dirty set (idempotent) |
| Screen | batch save (06 §S1 batch mode) |
E5 — Delete setting
| Endpoint | DELETE /settings/:key (settings.controller.ts:49-53) |
| Success | 200 data: null (void; envelope data = undefined → serialized as omitted/null) |
| Semantics | Soft delete via BaseRepository.softDelete — isDeleted:true, deletedAt, $inc version (base.repository.ts:68-74); doc remains; unique index still holds the key (setting.schema.ts:38) → recreate fails E11000 → 500 (OQ-5) |
| Errors | 404 RESOURCE_NOT_FOUND Setting "«key»" not found. (settings.service.ts:40); 401 |
| Screen | S4 delete confirm (06 §S4) — 404 treated as already removed |
Related surfaces (context, not module-owned)
E6 — Org embedded settings (design-docs/organizations/12)
GET /organizations/:id/settings→data = org.settings ?? {}(organizations.controller.ts:56-62)PATCH /organizations/:id/settings→ full-replace$set {settings: dto}(organizations.service.ts:129-137) — client must always submit the complete object.
E7 — Feature flags
GET /feature-flags[?module=],GET /feature-flags/enabled,GET /feature-flags/:key,PUT /feature-flags,PUT /feature-flags/bulk,DELETE /feature-flags/:key(feature-flags.controller.ts:23-58); flag doc{key, enabled, label?, description?, module?}(feature-flag.schema.ts:9-23).
Loading / streaming / realtime
| Screen | Loading | Streaming | Realtime |
|---|---|---|---|
| list | AppSkeleton(list) | — | — (no WS topic for settings; 00-shared/07 §8 has none) |
| detail | row/panel skeleton | — | — |
| save | button/row spinner | — | — |
| batch | per-row status | — | — |
Client-side error mapping table (module)
| Screen | code | UI |
|---|---|---|
| list/detail | 401 | silent refresh → session expiry |
| list | 5xx | AppErrorState + retry |
| detail | 404 | AppSettingNotFound empty state |
| save | 400 | inline field error (group enum details) |
| save | 500 | generic + requestId, form kept (retry safe — idempotent upsert) |
| save (E11000 recreate) | 500 | banner: key is soft-deleted, cannot recreate (OQ-5) |
| delete | 404 | treat as removed |
| any | 429 | countdown, no auto-retry |
| any (future) | 403 | shared 403 screen — server does not emit today (OQ-2) |
Optimistic / undo
- Explicit-save editors: server-confirm (config values; rollback never needed).
- Boolean toggle: optimistic with rollback (
00-shared/06 §3.5). - Delete: no undo (soft-delete not reversible via API).
- Upsert idempotency makes retries safe everywhere (
setting.repository.ts:35-39).
13 — State Management (Settings Module)
- 1. Cubit map
- 2. State objects (concise)
- 3. Cache & staleness
- 4. Optimistic vs full-save (per source semantics)
- 5. Realtime
- 6. Error states per action
- 7. Cross-cutting interplay
- 8. Testing hooks (
00-shared/06 §6)
Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) backed by
SettingsRepository(dio) which calls the endpoints in 12_API_Mapping.md. Server truth is the upsert response — config state must never diverge from it.
1. Cubit map
| Screen | Cubit | Events → State |
|---|---|---|
| List (hub) | SettingsListCubit | Load(group?), Refresh(), GroupChanged(group), Search(q), EditRow(key), ValueChanged(key, value), SaveRow(key), ToggleBool(key, value), EnterBatch(), SaveAll(), ExitBatch() → {LoadState, settings[] (grouped), selectedGroup, query, dirty: Map<key, SettingDraft>, batchMode, savingKeys: Set} |
| Detail | SettingDetailCubit | Load(key), FieldChanged(field, value), Save(), Delete(), DuplicateKey() → {LoadState, setting, draft, saving, deleted} |
| Create (sheet) | CreateSettingCubit | Create(form) → {idle, saving, created(setting), error(code)} |
Key reducer (list):
GroupChanged(g)→repository.list(group: g)(server filter —settings.controller.ts:26-28), clearsquery+ dirty set.SaveRow(key)→ builds full dto{key, value: draft.value, group: draft.group}→PUT /settings→ on success replace row from server doc (data: Setting,setting.repository.ts:39), remove fromdirty, snackbar; on error keepdirty+ snackbar (retry safe — idempotent).SaveAll()→ serializes only dirty rows (full dto each) →PUT /settings/bulk(settings.service.ts:28-34) → on 200 replace all rows from response array; on 5xx keep dirty set, snackbar "Saved N of M" with per-key retry.Search(q)— client-side filter of the current fetch (no serverqparam).
2. State objects (concise)
class SettingDoc { id, tenantId, key, Object? value, group, isEncrypted,
createdBy?, updatedBy?, version, createdAt, updatedAt; }
class SettingDraft { Object? value; SettingGroup group; } // per-row in dirty map
class SettingsState { LoadState load; List<SettingDoc> rows;
SettingGroup? selectedGroup; String query;
Map<String, SettingDraft> dirty; bool batchMode; Set<String> savingKeys; }
3. Cache & staleness
| Surface | Cache | TTL | Policy |
|---|---|---|---|
List per (group?) | in-memory + shared_preferences last-good | 5 min (config can change via API/other admins) | stale-while-revalidate (00-shared/06 §3.3); RefreshIndicator bypasses |
| Detail | none | — | always fetch on open (GET /settings/:key) |
| Server cache | none in settings path | — | API is single-doc Mongo reads |
Bump the list cache key whenever a save/delete succeeds ({tenant}:settings:{group}) so
sibling devices see fresh values on next focus. On screen focus → Refresh().
4. Optimistic vs full-save (per source semantics)
| Operation | Strategy | Reason (source) |
|---|---|---|
| Boolean toggle | optimistic + rollback | safe mutation class (00-shared/06 §3.5); instant feedback expected |
| String/number/JSON save | server-confirm | value replaced wholesale (setting.repository.ts:37); must reflect server truth |
| Batch save | server-confirm, per-item status | sequential loop, not transactional (settings.service.ts:28-34) |
| Delete | server-confirm; no undo | soft delete irreversible via API (OQ-5) |
| Create | server-confirm | upsert idempotent — retry-safe |
No offline write queue (00-shared/07 §10): offline → banner, writes blocked with
guidance; last-good list still renders.
5. Realtime
- No WS topic for settings (
00-shared/07 §8enumerates no settings channel) → no push-driven invalidation. Refresh-on-focus is the correctness mechanism; a futuresettings.changedtopic is(planned)(OQ-7/8).
6. Error states per action
| Action | Error | State → |
|---|---|---|
| load | 401 | silent refresh → sessionExpired → login |
| load | 5xx | AppErrorState(code, retry) |
| detail load | 404 | AppSettingNotFound (empty state) |
| save | 400 | inline field errors (group enum details) |
| save | 500 | snackbar + form kept (idempotent retry) |
| save (E11000) | 500 | banner "key cannot be re-created" (OQ-5) |
| bulk | 5xx mid-loop | "Saved N of M"; per-key retry re-sends full dirty set |
| delete | 404 | treat as removed |
| any | 429 | countdown, disable submit, no auto-retry |
7. Cross-cutting interplay
ConnectivityCubitgates writes (offline → editors disabled + banner).AuthCubitprovides tenantId/user (displayed in detail meta whencreatedBypresent).FeatureFlagsCubitnot consumed by this module (settings surface is not flag-gated).- Permission changes (role edit) → route rebuild hides
/settingswithoutsettings.read(00-shared/06 §3.6); server not enforcing (OQ-2).
8. Testing hooks (00-shared/06 §6)
- Unit:
SettingsListCubit— group switch triggers server filter call; dirty map lifecycle;SaveAllserializes only dirty full dtos; partial-failure state ("Saved N of M"). - Unit: retry-after-500 keeps dirty set.
- Widget: list loading/error/empty/dirty; JSON editor valid/invalid; batch bar counts.
14 — QA Checklist (Settings Module)
- 1. Functional
- 2. Tenancy & security
- 3. Visual / layout
- 4. Accessibility (00-shared/09)
- 5. Performance
- 6. Offline & network (00-shared/10 §2)
- 7. Tablet / desktop
- 8. Localization
- 9. Dark mode / animations
- 10. Server-backed edge mirrors
- QA scripts
Module QA specific to the framework + shared 00-shared/10. Every check derives from a specific backend behavior so failure analysis is direct.
1. Functional
-
GET /settingsreturns a bare array (nometa) sortedgroup,key(settings.service.ts:10-12;response-envelope.interceptor.ts:25-32). -
GET /settings?group=attendancereturns only that group; invalid group value → 400VALIDATION_ERRORwithdetails(settings.controller.ts:26-28;update-setting.dto.ts:14). -
GET /settings/:key→ 200 doc; unknown key → 404Setting "«key»" not found.(settings.service.ts:20). -
PUT /settingsupsert: new key creates; existing key replaces value (verify a nested object field dropped from payload is gone after save —setting.repository.ts:37); response is the saved doc (new: true). -
PUT /settingsomittinggroupkeeps existing group; sendinggroupmoves the key. -
label/descriptionsent inPUT /settingsare not persisted (verify DB —settings.service.ts:25). -
PUT /settings/bulksaves all; response array order matches input order (settings.service.ts:28-34). -
Boolean value round-trips as JSON
true/false; number stays number; numeric string stays string (no coercion). -
DELETE /settings/:key→ 200; second delete → 404 (settings.service.ts:40). - Re-create a deleted key → expect E11000 → 500 (OQ-5) — QA documents current behavior; UI shows the soft-delete banner.
-
Soft-deleted doc stays in DB with
isDeleted:true, deletedAt(base.repository.ts:68-74). - Bulk with a duplicate key inside the batch → last item wins (client dedupes first).
2. Tenancy & security
-
Tenant A's token can never read/write tenant B's settings (unique index
{tenantId,key},setting.schema.ts:38;scopedFilter,base.repository.ts:20-30). -
No
tenantIdaccepted from the body — only from token (repo injects,setting.repository.ts:34). -
Platform admin token →
GET /settingsreturns cross-tenant rows (scope bypassbase.repository.ts:21-23) — UI labels this mode; QA confirms no accidental write to wrong tenant. -
Permission matrix: any authenticated user can currently CRUD settings (no
@Permissionson controller, OQ-2). QA verifies client guard behavior (hidden routes); flag server enforcement as pending.
3. Visual / layout
- Long keys wrap without clipping at 200% text scale; preview ellipsis only.
- JSON values render truncated preview + expand into editor prefilled (never blank).
-
Dark-light parity: dirty tint
primaryContainer, JSON editor fill, chips. - Master-detail ≥840 dp; batch bar respects nav bar height on phone.
4. Accessibility (00-shared/09)
- TalkBack: browse → edit → save → delete fully operable without sight.
- VoiceOver separately.
-
Keyboard-only desktop: search (
Ctrl+F), Enter saves, Esc closes, focus ring visible. - Dirty rows announced ("unsaved"); JSON parse errors announced on focus.
- Group chips announce selected state; type chips are labeled (no color-only status).
- Contrast: body 4.5:1, icons/chips 3:1; reduced-motion honored.
5. Performance
- List first frame ≤ 300 ms (cache) / skeleton ≤ 200 ms, content ≤ 2 s (network).
- No duplicate parallel requests (group switch cancels in-flight previous fetch).
-
ListView.builderonly; no full-page rebuild on row save. - Memory stable over 20-min session (100+ keys).
6. Offline & network (00-shared/10 §2)
- Offline open → cached rows + banner; editors disabled with guidance.
- Slow network: row spinner; retry works; no silent double-write (idempotent PUT — two same payloads ⇒ one final value).
- Mid-bulk failure → "Saved N of M" + per-row retry sends full dirty set.
7. Tablet / desktop
- Master-detail two-pane; hover row tint; right-click row menu.
-
Ctrl/Cmd+Entersaves JSON editor;N/+new setting; Esc exits batch mode.
8. Localization
-
All strings via i18n; group labels localized (
attendance→ "Attendance"); enum values are the wire values (never translated in requests). -
RTL: group chips scroll direction, JSON editor LTR-aligned (
monoblock).
9. Dark mode / animations
- Full visual pass both modes; tokens only; motion tokens only; reduced-motion pass.
10. Server-backed edge mirrors
- 400 group enum details rendered inline.
- 404 detail/delete treated as removed.
- 500 (missing key/value, E11000) → generic + requestId, form kept, banner for soft-delete case (OQ-3, OQ-5).
- 429 → countdown, no auto-retry.
QA scripts
- Golden per component (light/dark × phone/tablet/desktop × dirty/saving/error states).
- E2E (device-cloud P0): list → group filter → edit string → toggle bool → batch save → delete (device-cloud; mock/seed tenant with ~20 keys across all 6 groups).
- Tenancy smoke: second tenant token → expect 404/empty, never other tenant's rows.
- Upsert idempotency smoke: same PUT twice → identical
versionincrement pattern (second call may bump version — verify no duplicate key rows).
15 — Flutter Implementation Guide (Settings Module)
- 1. Folder structure
- 2. Dependencies
- 3. SettingsRepository (single)
- 4. Cubits
- 5. Navigation
- 6. Theme
- 7. Key type handling (module core)
- 8. Localization keys
- 9. Error handling
- 10. Testing
- 11. Performance
- 12. Proposals flagged to the team
How to build the Settings feature in the Flutter client on top of 00-shared/11. Forward-looking spec; no client repo exists yet. Data-driven module: no hardcoded key catalog — render what
GET /settingsreturns (settings.controller.ts:24-29).
1. Folder structure
features/settings/
├── domain/
│ ├── models/
│ │ ├── setting_doc.dart # key, Object? value, group, isEncrypted, version, dates
│ │ ├── setting_draft.dart # {Object? value, SettingGroup group}
│ │ └── setting_group.dart # enum mirroring setting.schema.ts:7-14 (wire values!)
├── data/
│ ├── dto/
│ │ ├── setting_dto.dart # envelope-payload mapper + toJson for PUT
│ │ └── update_setting_dto.dart # {key, value, group?} — mirrors update-setting.dto.ts
│ └── repositories/
│ └── settings_repository.dart
└── presentation/
├── cubit/
│ ├── settings_list_cubit.dart
│ ├── setting_detail_cubit.dart
│ └── create_setting_cubit.dart
├── pages/
│ ├── settings_list_page.dart
│ └── setting_detail_page.dart
└── widgets/
├── setting_row.dart
├── setting_group_chips.dart
├── typed_value_editor.dart # dispatcher
├── json_editor.dart
├── bool_row.dart
├── save_bar.dart
└── org_settings_link.dart
2. Dependencies
flutter_bloc, dio (AppDio interceptors), go_router, get_it, shared_preferences
(cache + banner dismissal), intl (dates). No JSON editor package — hand-rolled
jsonDecode validation keeps the dependency surface zero (07 §B).
3. SettingsRepository (single)
class SettingsRepository {
Future<List<SettingDoc>> list({SettingGroup? group}); // GET /settings[?group=]
Future<SettingDoc> byKey(String key); // GET /settings/:key
Future<SettingDoc> upsert(UpdateSettingDto dto); // PUT /settings
Future<List<SettingDoc>> upsertAll(List<UpdateSettingDto> dtos); // PUT /settings/bulk
Future<void> remove(String key); // DELETE /settings/:key
}
- All through
AppDio; envelopes mapped insetting_dto.dart; non-paginated arrays (no meta parsing —12 §0). - Cache:
shared_preferenceslast-good per{tenant}:settings:{group}, TTL 5 min, stale-while-revalidate (13 §3).
4. Cubits
SettingsListCubit—Load(group?),Refresh(),GroupChanged,Search,SaveRow,ToggleBool,EnterBatch,SaveAll,ExitBatch(13 §1).SettingDetailCubit—Load(key),FieldChanged,Save,Delete,DuplicateKey.CreateSettingCubit—Create(form).- All pure-Dart; DI via
get_itlazy factories (00-shared/11 §2).
5. Navigation
GoRoute(path: '/settings', redirect: _permGuard('settings.read'),
builder: (_, __) => SettingsListPage()),
GoRoute(path: '/settings/group/:group', ...), // pre-selects chip
GoRoute(path: '/settings/:key', builder: (_, s) => SettingDetailPage(key: s.pathParameters['key']!)),
- Master-detail via
StatefulShellBranchat ≥840 dp (00-shared/05 §3). _permGuardreadsuser.permissions— client-side only until server enforces@Permissions('settings.*')(OQ-2; then 403 → same redirect via error-code map).- Deep links
(forward-looking):studylyon://settings,studylyon://settings/:key.
6. Theme
AppTheme.light()/dark() unchanged; org branding override re-seeds ColorScheme
(00-shared/04 §7.5); no module tokens beyond 11_Design_System_Mapping.md.
7. Key type handling (module core)
enum ValueKind { str, num, bool_, json }
ValueKind kindOf(Object? v) => switch (v) {
String() => ValueKind.str, num() => ValueKind.num,
bool() => ValueKind.bool_, _ => ValueKind.json };
- Editors dispatch on
kindOf(06 §S2); no coercion on save — serialize withjsonEncodeonly for the JSON editor; strings/numbers/booleans sent raw (value: unknownwire type,update-setting.dto.ts:10). SettingGroupenum uses wire values (setting.schema.ts:7-14) for request/response; display labels via i18n keys.
8. Localization keys
settings.list.title, settings.list.search.hint, settings.group.{academic|attendance|grading|notification|theme|general},
settings.row.type.{str|num|bool|json}, settings.row.dirty, settings.save.all(n),
settings.save.saved_n_of_m(n,m), settings.save.error, settings.delete.confirm,
settings.delete.softdelete.warn, settings.json.invalid, settings.notfound,
settings.orglink.banner, settings.create.title, settings.create.key.hint,
settings.create.duplicate.warn. Server business-4xx messages mapped to keys; codes drive
the rest (00-shared/11 §9).
9. Error handling
ApiException(code, status)fromAppDiointerceptor (00-shared/11 §5).- 400 → inline field errors from
details[]; 404 →AppSettingNotFound; 500 → generic + requestId; 429 → countdown; 401 → refresh → session expiry. - E11000 recreate case → dedicated banner copy (
14 §10, OQ-5).
10. Testing
- Unit: cubits with mocked repository (group-switch server-call assert; dirty lifecycle;
SaveAllsends only dirty full dtos; partial-failure "Saved N of M" + retry keeps dirty). - Unit:
kindOfdispatch; JSON editorjsonDecodevalidator. - Widget: list loading/error/empty/dirty/batch; typed editors per kind; save bar counts.
- Golden:
setting_row(clean/dirty/saving),json_editor(valid/invalid),save_bar— light/dark × 3 sizes. - Integration: seed tenant (20 keys, all groups) → list → filter → edit string → toggle bool (instant save) → batch save → delete → verify DB state via API.
- E2E (P0): admin edits two settings across groups and batch-saves on device cloud.
11. Performance
ListView.builder+constconstructors; row save updates a single item (identity-keyed list diff), never full rebuild.- Group switch cancels the in-flight previous fetch (client-visible latency guard).
- JSON editor
TextEditingControllerdebounced (300 ms) for parse checks. - No images, no heavy widgets — the list is text-dense; profile against
00-shared/10 §1budgets.
12. Proposals flagged to the team
- When server adds
@Permissions('settings.*')(permissions.constants.ts:75-77), enable server-truth 403 handling (OQ-2). - When the audit module exposes settings history, add the detail "History" tab (OQ-7).
- When encryption pipeline lands, surface
isEncryptedbadge + secret editors (OQ-6). - When
organization_settingscollection /COACHINGgroup land (COLLECTIONS.md:767-783,IMPLEMENTATION_PLAN.md:773), extend groups and cross-links. - If a settings registry with defaults ships, replace type-inference heuristics with registry-driven editors.
01 — Product Overview (Staff Module)
- 1. Purpose
- 2. Module boundary: Staff vs Teachers vs Users
- 3. Staff lifecycle (end-to-end)
- 4. Department & designation catalog lifecycle
- 5. Role & permission mapping
- 6. Events & downstream consumers
- 7. Data model summary
- 8. Dependencies
- 9. Edge cases & invariants
- 10. PRD & mobile-forward note
StudyLyon — multi-tenant ERP / School Management API. This package designs the Staff module client (Flutter, forward-looking spec) against the implemented NestJS backend. Every endpoint, field, enum, event, and rule below is derived from
src/modules/staff/**,src/modules/rbac/**,src/database/**,src/infrastructure/bullmq/event-queue-map.ts, and the blueprint (studylyon-blueprint/04-Modules/Staff.md,03-Database/COLLECTIONS.md). Nothing is invented; gaps live in Assumptions & Open Questions and are marked(planned)/(proposed).
1. Purpose
The Staff module is the non-teaching employment domain: "who works the office, finance desk, front desk, library, transport, HR" of an institution. It owns:
| Responsibility | Source |
|---|---|
| Staff profile CRUD (create / list / detail / update / deactivate) | staff.controller.ts:24-38 |
| Duplicate guards (employeeNumber) on create | staff.service.ts:31-36 |
Soft-delete (deactivate) with isDeleted flag — no re-activation endpoint | base.repository.ts:68-74; staff.service.ts:93-104 |
Employment type enum (full_time/part_time/contract/intern) | staff.schema.ts:14-19 |
Staff status enum (active/inactive/on_leave/terminated) | staff.schema.ts:7-12 |
| Department catalog CRUD (name, code, head) | department.controller.ts:26-43; department.schema.ts:9-19 |
| Designation catalog CRUD (name, level, optional department link) | designation.controller.ts:26-43; designation.schema.ts:9-19 |
| Domain events → in-app notification, audit-write, search index | staff.service.ts:44-54,82-90,96-103; event-queue-map.ts:34-36; search-indexer.service.ts:11,18,25 |
Blueprint definition: Staff.md:3 — "Non-teaching employees." Collections:
staff, departments, designations (Staff.md:7).
2. Module boundary: Staff vs Teachers vs Users
The backend splits "people" into parallel profile documents sharing the same employment skeleton but diverging on domain specifics:
| Aspect | Staff (staff) | Teacher (teachers) |
|---|---|---|
| Identity | "Non-teaching employees." (Staff.md:3) | "Teaching profile." (blueprint) |
| Employment fields | employmentType, salaryGrade (staff.schema.ts:35-47) | qualification, experienceYears (teacher.schema.ts:38-42) |
| Academic linkage | none | subjects[], classTeacherFor[] (teacher.schema.ts:44-48) |
| Status field | status — StaffStatus enum (staff.schema.ts:7-12,48-49) | employmentStatus — same 4 values (teacher.schema.ts:7-12,31-36) |
| Unique indexes | tenantId+employeeNumber, tenantId+userId (staff.schema.ts:57-58) | identical pattern (teacher.schema.ts:56-57) |
| CRUD surface | staff.controller.ts:24-38 | teacher.controller.ts:24-38 |
| Events | StaffCreated/Updated/Deleted | TeacherCreated/Updated/Deleted |
| Reference catalogs | departments/designations owned here (Staff.md:59) | shared, owned by Staff module |
Users (users collection) holds identity/credentials; a users record may own
multiple profiles — a single userId can back both a teacher and a staff record in
parallel (RELATIONSHIPS.md:28 — staff (0..1) via userId). The Staff screens render
only staff records; the Teachers screens render only teacher records.
Two catalogs owned by this module (Staff.md:59 — "Departments/designations are
reference catalogs owned by this module"):
- Departments (
departmentscollection): flat org units. Example seeds fromCOLLECTIONS.md:1520-1533: Administration, Science, Commerce, Accounts, HR, Library, Transport. Fields:name,code,headId(refStaff),status(department.schema.ts:9-19). - Designations (
designationscollection): job titles. Example seeds fromCOLLECTIONS.md:1562-1573: Principal, Vice Principal, Teacher, Librarian, Receptionist, Accountant. Fields:departmentId?(refDepartment),name,level(numeric rank),status(designation.schema.ts:9-19).
Designations optionally link to a department (designation.schema.ts:9-10) — the
link is a reference only, not enforced (see OQ-3).
3. Staff lifecycle (end-to-end)
User account exists (Users module; email welcome via UserRegistered → emails queue)
→ Staff profile created POST /api/v1/staff [StaffCreated → in-app 'staff-created']
→ Employment record maintained: dept/designation/type/salary grade
→ Profile edited PATCH /api/v1/staff/:id [StaffUpdated → audit-write]
→ Deactivated DELETE /api/v1/staff/:id [StaffDeleted → audit-write]
(soft-delete: isDeleted=true + deletedAt + deletedBy; all queries exclude)
- Create always sets
status: StaffStatus.ACTIVE(staff.service.ts:39) — the create DTO cannot set status. employmentTypedefaults tofull_timewhen omitted (staff.service.ts:40-41).- Deactivation is permanent from the client's perspective:
DELETEsoft-deletes (base.repository.ts:68-74) and no re-activation endpoint exists. - Plan test rows: PLAN.md:38 (3.3 "Invite staff member (non-teaching) → email sent"
via
POST /api/v1/staff→StaffCreated), PLAN.md:40 (3.5 "Deactivate staff member → soft-delete" →StaffDeletedevent, queries exclude).
4. Department & designation catalog lifecycle
Department: POST /api/v1/departments (name required, duplicate name → 409)
PATCH /api/v1/departments/:id (rename, recode, reassign head)
DELETE /api/v1/departments/:id (soft-delete; no member-count guard — OQ-4)
Designation: POST /api/v1/designations (name required, duplicate name → 409)
PATCH /api/v1/designations/:id
DELETE /api/v1/designations/:id (soft-delete)
Catalogs are status-flagged, not deleted from history: status defaults to
'active' on create (department.service.ts:28, designation.service.ts:31);
DELETE sets isDeleted so the row disappears from all lists
(base.repository.ts:20-30,68-74). No events are emitted by either service —
no DepartmentCreated/DesignationCreated events exist in the event map
(event-queue-map.ts has no department/designation entries; both services inject
only their repository, department.service.ts:19-20, designation.service.ts:22-23).
5. Role & permission mapping
Permissions exist in permissions.constants.ts:19-24:
| Permission | Intended scope | Notes |
|---|---|---|
staff.read | View staff list/detail | |
staff.create | Create staff profile | |
staff.update | Edit staff profile | |
staff.delete | Deactivate staff | |
department.manage | Manage department catalog | single perm covers CUD |
designation.manage | Manage designation catalog | single perm covers CUD |
Default role wiring (role.schema.ts):
| Role | Slug | Permissions relevant to Staff module |
|---|---|---|
| Organization Admin | org_admin | ALL_PERMISSIONS — includes all six above (role.schema.ts:17-24) |
| Staff | staff | ['student.read'] only — no staff module perms (role.schema.ts:33-40) |
| Teacher / Accountant / Parent / Student | — | none of the six (role.schema.ts:25-48) |
Consequences for the client:
- The default Staff role cannot open the Staff section; HR/Admin must grant
staff.*perms via a custom RBAC role (rbacmodule) before the section is usable. - Server enforcement gap: staff controllers declare only
@UseGuards(JwtAuthGuard)(staff.controller.ts:21); no@Permissions()decorators are applied, unlike webhooks/search/files/scheduler (webhooks.controller.ts:22,search.controller.ts:16). TheRbacGuardexists (rbac.guard.ts:14-52) and is wired for other modules. Until staff controllers adopt it, permission checks are client-enforced only (mirror ofpermissions.constants.ts); treat server-side RBAC on these endpoints as(planned)— OQ-1.
6. Events & downstream consumers
| Event | Queue / job (event-queue-map.ts) | Consumer intent |
|---|---|---|
StaffCreated | in-app / staff-created (line 34) | In-app notification fan-out |
StaffUpdated | audit-write / log-staff-updated (line 35) | Audit trail |
StaffDeleted | audit-write / log-staff-deleted (line 36) | Audit trail |
StaffCreated/Updated/Deleted | Search indexer ENTITY_EVENTS (search-indexer.service.ts:11,18,25) | Search index upsert/remove |
Payloads: StaffCreated = {staffId, employeeNumber} (staff.service.ts:50-53);
StaffUpdated = {staffId, changes: Object.keys(dto)} (line 88); StaffDeleted =
{staffId} (line 102).
Observed gap (flag): the search indexer resolves entity ids from
payload.entityId ?? payload._id ?? ... (search-indexer.service.ts:62-67) and
titles from payload.name/title/firstName/... (lines 68-75). Staff payloads carry
staffId (not entityId/_id) and employeeNumber (not name), so staff creates
exit early at search-indexer.service.ts:88 — staff records are not indexed.
Verified in OQ-6.
Email: the emails queue worker handles only UserRegistered and
PasswordResetRequested (email.worker.ts:25-42); StaffCreated logs "No handler
for email event" (line 41). PLAN.md:38's "email sent" is satisfied at user
invitation time (UserRegistered), not at staff-profile creation — the client must
not promise a staff-creation email.
7. Data model summary
All three collections extend BaseSchema (base.schema.ts:8-35): tenantId
(required), createdBy/updatedBy/deletedBy, isDeleted (soft-delete, default
false), deletedAt, version (optimistic lock, $inc on every write —
base.repository.ts:63,71), createdAt, updatedAt. Every repository query injects
tenantId + isDeleted: false (base.repository.ts:20-30); platform admin bypasses
tenant scope but never sees deleted rows.
| Collection | Unique index | Ref fields |
|---|---|---|
staff | {tenantId, employeeNumber}; {tenantId, userId} (staff.schema.ts:57-58) | userId→User (required), departmentId→Department, designationId→Designation |
departments | {tenantId, name} (department.schema.ts:24) | headId→Staff |
designations | {tenantId, name} (designation.schema.ts:24) | departmentId→Department |
metadata is an open Object on staff (staff.schema.ts:51-52; update-staff.dto.ts:42)
— any key/value passthrough, unvalidated.
8. Dependencies
- Users — identity link (
userId,Staff.md:49). A staff record requires an existinguserId(create-staff.dto.ts:5-7). - RBAC — permission gating (
Staff.md:50). - Attendance / Leave / Payroll —
(planned)future consumers (Staff.md:51-52; salary grade is a reference, not payroll —Staff.md:60).
9. Edge cases & invariants
- Duplicate
employeeNumberwithin tenant → 409DUPLICATE_RESOURCE(staff.service.ts:32-36); duplicateuserIdwithin tenant → unique index violation → 409 (same code path viahttp-exception.filter.ts:32). - Duplicate department/designation
namewithin tenant → 409 (department.service.ts:23-25,designation.service.ts:25-28). - Unknown/invalid
:id→ 404RESOURCE_NOT_FOUND(staff.service.ts:60;http-exception.filter.ts:47-48,91-92maps CastError → 400). - Cross-tenant IDs: repository tenant-scoping makes them return 404, never leak
(
base.repository.ts:20-30). - Soft-deleted staff never reappear in lists; a second
DELETEon a deleted record → 404 (staff.service.ts:94-95). - Page/limit:
page≥ 1,limit1–100 default 20 (pagination-query.dto.ts:5-30).sort/qare accepted by the DTO but not applied by staff list queries (staff.service.ts:64-76— see OQ-2).
10. PRD & mobile-forward note
The PRD puts native mobile apps out of Phase 1 scope
(PRODUCT_REQUIREMENTS_DOCUMENT.md:144) — per the shared-package decision
(00-shared/01 §9, 00-shared/12 A1) these docs specify a full-featured Flutter
client to the complete API surface. For Staff specifically, mobile matters most for
HR on the go: quick lookups (employee number, department roster), status toggles,
and designation catalogs; heavy data-entry (bulk import) remains desktop/web. The
screen specs are responsive (phone/tablet/desktop) per 00-shared/02 §8.
02 — User Personas (Staff Module)
- P1 — Org Admin (primary actor)
- P2 — HR Manager (primary daily actor)
- P3 — Staff Member (self-service, read-mostly)
- P4 — Accountant / Receptionist as staff types
- P5 — Designation/Department viewer (cross-module
(proposed))
Personas for the Staff module, derived from the blueprint personas (
studylyon-blueprint/01-Product/USER_PERSONAS.md), the default role catalog (role.schema.ts), and the module's permission set (permissions.constants.ts:19-24).(proposed)marks behavioral detail not derivable from source.
P1 — Org Admin (primary actor)
"Configure branches, invite staff, oversee operations." (
USER_PERSONAS.md:22-25)
- Who: tenant owner/principal who runs the institution; has the
org_adminrole withALL_PERMISSIONS(role.schema.ts:17-24), so all six staff permissions (permissions.constants.ts:19-24). - Goals:
- Stand up the org structure once: create departments, then designations
(
department.controller.ts:26-43,designation.controller.ts:26-43). - Invite non-teaching staff: create the profile against an existing user
(
create-staff.dto.ts:5-7), watch theStaffCreatedevent fan out (event-queue-map.ts:34). - Deactivate leavers so they drop out of every query
(
base.repository.ts:20-30,68-74).
- Stand up the org structure once: create departments, then designations
(
- Frustrations: duplicate employee numbers causing 409s
(
staff.service.ts:32-36); forgetting a leaver and having them still appear. - Success metric: onboarding a new hire in < 2 minutes; deactivating in < 30 s.
P2 — HR Manager (primary daily actor)
A custom RBAC role is the only way to grant non-admin staff management — the default Staff role has just
student.read(role.schema.ts:33-40). The HR manager is a(proposed)concrete persona for "user withstaff.*permissions" — therbacmodule creates it (rbac.controller.ts), but no default role carries these perms exceptorg_admin.
- Who: runs employee records day-to-day: onboarding, transfers, status changes.
- Permissions used:
staff.read,staff.create,staff.update,staff.delete,department.manage,designation.manage(permissions.constants.ts:19-24). - Goals:
- Find any employee by employee number or name — search is not server-side
for staff lists (
staff.service.ts:64-76, OQ-2); HR relies on pagination + client-side filtering. - Maintain employment terms:
employmentType,joiningDate,salaryGrade,departmentId,designationId(update-staff.dto.ts:6-33). - Set status explicitly (
active/inactive/on_leave/terminatedviaupdate-staff.dto.ts:35-38; enumstaff.schema.ts:7-12) — e.g., "on_leave" for sabbaticals without deleting. - Keep the department catalog tidy (rename, recode, set
headIddepartment.dto.ts:20-39) and the designation catalog ranked (leveldesignation.dto.ts:14-17).
- Find any employee by employee number or name — search is not server-side
for staff lists (
- Frustrations: no bulk import endpoint for staff (
Staff.md:17lists bulk import as a responsibility — not implemented, OQ-5); client-side-only filtering on big rosters.
P3 — Staff Member (self-service, read-mostly)
- Who: non-teaching employee with the default
staffrole —['student.read'](role.schema.ts:33-40). Cannot see the Staff module without an RBAC grant (OQ-1) and has no profile-self-edit endpoint:update-staff.dto.tshas no self-scoping; PATCH is by:id(staff.controller.ts:33). - Goals
(proposed): view own employment record (department, designation, employment type, joining date, salary grade) in a "My profile" read-only view; everything else comes from other modules (students read, notifications, leave). - Reality check: the backend offers no self-service staff endpoint. If the
product needs self-view, it must be built on
GET /staff/:idguarded client-side (any caller withstaff.readcan read any staff record) — flag to product (OQ-7).
P4 — Accountant / Receptionist as staff types
- Who: two of the module's actual designated staff: the blueprint seeds
AccountantandReceptionistdesignations (COLLECTIONS.md:1571-1572) and departmentsAccounts(COLLECTIONS.md:1529). These are staff profiles, not separate roles: their login roles areaccountant(role.schema.ts:41-48, permissionsfees.collect,student.read) orstaff— the designation is data, the role is RBAC. - Goals:
- Accountant: fee desk work (Fees module) while being listed under
Accountsdepartment with designationAccountantfor the org chart. - Receptionist: front-desk support (CRM/leads 05_Global_Information_Architecture.md:38), reachable in the staff directory.
- Accountant: fee desk work (Fees module) while being listed under
- Key UX insight: department/designation pickers must offer the seeded catalogs
immediately (
COLLECTIONS.md:1520-1533,1562-1573) and stay empty-state friendly when an org hasn't configured them yet.
P5 — Designation/Department viewer (cross-module (proposed))
- Teachers reference the same
departments/designationscatalogs (teacher.schema.ts:22-26), but the catalogs are owned by this module (Staff.md:59). Any screen that displays a teacher's department (e.g., Teachers module detail) needs department/designation data — the Staff module client serves as the canonical catalog consumer.
03 — User Journeys (Staff Module)
- J1 — Create a staff member (Org Admin / HR)
- J2 — Manage departments (Org Admin / HR)
- J3 — Manage designations (Org Admin / HR)
- J4 — Edit a staff profile (HR)
- J5 — Deactivate a staff member (HR / Org Admin)
- J6 — Browse the staff directory (read-only
(proposed))
End-to-end journeys mapped to the exact API surface. All backend behavior cited from source; flows marked
(proposed)for client-only decisions.
J1 — Create a staff member (Org Admin / HR)
Backend steps: POST /staff (staff.controller.ts:24) → duplicate guard
(staff.service.ts:31-36) → create with status: ACTIVE, employmentType
defaulting to full_time (staff.service.ts:37-43) → StaffCreated event
(staff.service.ts:44-54 → in-app queue, event-queue-map.ts:34).
flowchart LR
A[Admin opens Staff list] --> B[Taps FAB Add staff]
B --> C[Create Staff form]
C -->|user selected from Users list| D[POST /api/v1/staff]
D --> E{employeeNumber unique?}
E -- no --> F[409 DUPLICATE_RESOURCE<br/>inline error on employeeNumber]
F --> C
E -- yes --> G[201 + staff record<br/>status=active default]
G --> H[StaffCreated event<br/>in-app notification]
H --> I[Snackbar 'Staff created'<br/>navigate to detail]
- Form fields:
userId(required picker),employeeNumber(required),departmentId,designationId,employmentType,joiningDate,salaryGrade(create-staff.dto.ts:5-38). statusis not settable at creation (staff.service.ts:39).- Haptic:
lightImpacton success (00-shared/08 §3).
J2 — Manage departments (Org Admin / HR)
Backend steps: POST /departments duplicate-name guard (department.service.ts:22-30);
list via GET /departments (department.controller.ts:29); update
PATCH /departments/:id; delete DELETE /departments/:id (soft).
No events emitted by the catalog services (department.service.ts:19-20).
flowchart TD
A[Departments list] --> B[FAB Add department]
B --> C[POST /api/v1/departments<br/>name required, code?, headId?]
C --> D{name duplicates?}
D -- yes --> E[409 inline error]
E --> C
D -- no --> F[201 department<br/>status=active]
F --> G[Row appears in list]
A --> H[Row menu: Edit / Deactivate]
H --> I[PATCH /api/v1/departments/:id<br/>name, code, headId, status]
H --> J[DELETE /api/v1/departments/:id]
J --> K[404 if already deleted]
K --> L[Removed from list<br/>no member-count check OQ-4]
headIdpicker sources staff (department.schema.ts:15-16); a soft-deleted head renders as unlinked ((proposed)— backend stores the bare ref).- Dept→staff counts and "reassign members before deleting" are not enforced server-side — see OQ-4; the client must present the risk in the confirm dialog.
J3 — Manage designations (Org Admin / HR)
Backend steps: POST /designations duplicate-name guard (designation.service.ts:25-28);
level is IsInt (designation.dto.ts:14-17); optional departmentId
(designation.schema.ts:9-10).
flowchart LR
A[Designations list] --> B[FAB Add designation]
B --> C[POST /api/v1/designations<br/>name required, level?, departmentId?]
C --> D{name duplicates?}
D -- yes --> E[409 inline error]
D -- no --> F[201 designation<br/>level default 0, status active]
F --> G[List, sorted by level<br/>client-side proposed]
A --> H[Edit: PATCH /api/v1/designations/:id]
H --> I[Update name/level/dept/status]
A --> J[Delete: DELETE /api/v1/designations/:id]
leveldefaults to0(designation.schema.ts:15-16); sort-by-level is(proposed)— the API returns insertion order (designation.service.ts:41-50).
J4 — Edit a staff profile (HR)
Backend steps: PATCH /staff/:id (staff.controller.ts:33) → existence check 404
(staff.service.ts:79) → $set + version++ (base.repository.ts:57-66) →
StaffUpdated with changed keys (staff.service.ts:82-90).
flowchart LR
A[Staff detail] --> B[Edit button]
B --> C[Edit form prefilled from GET /staff/:id]
C --> D[PATCH /api/v1/staff/:id]
D --> E{exists?}
E -- no --> F[404 RESOURCE_NOT_FOUND]
F --> G[Empty-state 'not found']
E -- yes --> H[200 updated record]
H --> I[StaffUpdated → audit-write]
I --> J[Snackbar 'Saved'<br/>detail refreshes]
- All fields optional on update (
update-staff.dto.ts:5-42) — send only changed fields;changeslist is the audit delta (staff.service.ts:88). statusmay be set here — this is the sanctioned way to move a member toon_leave/inactive/terminated(update-staff.dto.ts:35-38).
J5 — Deactivate a staff member (HR / Org Admin)
Backend steps: DELETE /staff/:id (staff.controller.ts:36) → soft delete sets
isDeleted + deletedAt + deletedBy and bumps version (base.repository.ts:68-74)
→ 404 when already deleted (staff.service.ts:94-95) → StaffDeleted audit event
(staff.service.ts:96-103).
flowchart LR
A[Staff detail or row menu] --> B[Deactivate]
B --> C{Confirm dialog<br/>'This removes the staff member from all lists'}
C -- cancel --> A
C -- confirm --> D[DELETE /api/v1/staff/:id]
D --> E{found & active?}
E -- no --> F[404 RESOURCE_NOT_FOUND]
E -- yes --> G[200 empty data<br/>StaffDeleted → audit-write]
G --> H[Navigate back to list<br/>row gone; snackbar]
- No restore path exists (no un-delete endpoint) — the dialog must say so
(
base.repository.ts:68-74; OQ-8 for a future restore). - The list refetches because soft-deleted rows are excluded from every query
(
base.repository.ts:20-30).
J6 — Browse the staff directory (read-only (proposed))
GET /staff?page=&limit= (staff.controller.ts:27) — no server-side q/sort
for staff (staff.service.ts:64-76, OQ-2). Journey: open Staff → list loads →
pull-to-refresh → infinite scroll / page controls → tap row → detail with tabs.
Status badges map 1:1 to StaffStatus (staff.schema.ts:7-12).
04 — Information Architecture (Staff Module)
- 1. Position in the global IA
- 2. Staff module map
- 3. Entry points
- 4. Information hierarchy of a staff record
- 5. Navigation model (per shared shell)
- 6. Screen inventory (short form)
- 7. Information hygiene rules
Where Staff screens live in the global shell (00-shared/05) and how they nest. Navigation/routing decisions not fixed by backend are
(proposed); the permission gate is the client mirror ofpermissions.constants.ts:19-24(server RBAC on these endpoints is not wired —staff.controller.ts:21, OQ-1).
1. Position in the global IA
Global destination table: Staff → /staff → roles: admin, HR
(00-shared/05 §2). Phone: bottom NavigationBar (≤ 5 destinations) with Staff
under the drawer when not a primary tab; tablet/desktop: NavigationRail
(00-shared/05 §3).
2. Staff module map
/staff (permission: staff.read)
├── list (staff list: filters, pagination)
├── new (create form — staff.create)
└── :id (detail — staff.read)
├── (tabs: Overview | Employment | Metadata — proposed)
└── edit (staff.update)
/departments (permission: department.manage)
├── list
├── new (department.create → POST /departments)
└── :id
└── edit
/designations (permission: designation.manage)
├── list
├── new
└── :id
└── edit
Route constants mirror the API nouns exactly (staff.controller.ts:20,
department.controller.ts:22, designation.controller.ts:22).
3. Entry points
| From | To | Why |
|---|---|---|
| Dashboard | /staff | Org chart / roster overview (proposed) |
| Navigation (admin/HR) | /staff | Primary management surface (00-shared/05 §2) |
| Users detail | /staff (filtered) | Link userId → staff profile (proposed) — users and staff are separate collections (RELATIONSHIPS.md:28) |
| Teachers detail | /departments/:id | Teacher references the same catalogs (teacher.schema.ts:22-26) |
| Global search (when backend supports staff) | /staff/:id | Search indexer currently skips staff (OQ-6) |
4. Information hierarchy of a staff record
- Identity anchor:
employeeNumber— unique per tenant (staff.schema.ts:57) and the label users type/quote. - Org placement:
departmentId→designationId(both optional refs,staff.schema.ts:29-33). - Employment terms:
employmentType(enum, defaultfull_time),joiningDate,salaryGrade(staff.schema.ts:35-47). - Lifecycle:
statusenum (staff.schema.ts:7-12,48-49). - System meta:
version,createdAt,updatedAt(base.schema.ts:29-34) — displayed in an "Audit" tab(proposed);metadatapassthrough (staff.schema.ts:51-52).
5. Navigation model (per shared shell)
- Phone: list → push detail (stack); tabs inside detail; FAB per screen
(
00-shared/05 §6-7). - Tablet ≥ 840 dp: master-detail — list left, detail right
(
00-shared/04 §6,00-shared/05 §3). - Desktop: same master-detail + breadcrumbs (
Staff / Administration / HR) at depth > 2 (00-shared/05 §3). - Deep links
(proposed):studylyon://staff/:id,studylyon://departments/:id,studylyon://designations/:id.
6. Screen inventory (short form)
| # | Screen | Route | Perm | Backend source |
|---|---|---|---|---|
| S1 | Staff list | /staff | staff.read | staff.controller.ts:27-28 |
| S2 | Staff detail | /staff/:id | staff.read | staff.controller.ts:30-31 |
| S3 | Create staff | /staff/new | staff.create | staff.controller.ts:24-25 |
| S4 | Edit staff | /staff/:id/edit | staff.update | staff.controller.ts:33-34 |
| S5 | Deactivate flow | modal on S1/S2 | staff.delete | staff.controller.ts:36-37 |
| S6 | Department list | /departments | department.manage | department.controller.ts:29-30 |
| S7 | Department detail | /departments/:id | department.manage | department.controller.ts:32-33 |
| S8 | Department form (create/edit) | /departments/new, /departments/:id/edit | department.manage | department.controller.ts:26-27,35-40 |
| S9 | Designation list | /designations | designation.manage | designation.controller.ts:29-30 |
| S10 | Designation detail | /designations/:id | designation.manage | designation.controller.ts:32-33 |
| S11 | Designation form (create/edit) | /designations/new, /designations/:id/edit | designation.manage | designation.controller.ts:26-27,35-40 |
Full detail in 05_Screen_Inventory.md, 06_Screen_Specifications.md.
7. Information hygiene rules
- Employee number and names display in
mono/ tabular figures (00-shared/02 §2). - Status is never color-only: badge + icon + label (
00-shared/09 §9). - Missing optional refs (no department/designation) render "—" with an "assign" affordance, never a broken ref.
- Deleted heads (
headIdrefs a soft-deleted staff) render as unassigned(proposed)— server stores the bare ObjectId (department.schema.ts:15-16).
05 — Screen Inventory (Staff Module)
- S1 — Staff List (
/staff) - S2 — Staff Detail (
/staff/:id) - S3 — Create Staff (
/staff/new) - S4 — Edit Staff (
/staff/:id/edit) - S5 — Deactivate Flow (modal, from S1/S2)
- S6 — Department List (
/departments) - S7 — Department Detail (
/departments/:id) - S8 — Department Form (create + edit)
- S9 — Designation List (
/designations) - S10 — Designation Detail (
/designations/:id) - S11 — Designation Form (create + edit)
- Cross-screen state rules (shared 00-shared/06 §3.1)
Eleven screens across three route groups. All backend-facing behavior cited from
src/modules/staff/**. Layout, copy, and client behaviors follow the shared tokens and components (00-shared/02, 03,05). Screens are(planned)client-side; the API surface they map to is implemented.
S1 — Staff List (/staff)
- Purpose: browse the non-teaching roster; entry to detail; quick actions.
- Data:
GET /staff?page=&limit=(staff.controller.ts:27-28); responsedata: Staff[],meta(staff.service.ts:64-76; enveloperesponse-envelope.interceptor.ts:20-32,55-57). - List row: avatar (initials from user — staff profile has no name field;
join with users for display,
(proposed)),employeeNumber(title), department/designation (subtitle),statusbadge + overflow menu (staff.schema.ts:22-52). - Filters
(proposed): client-side chips forstatus(exact enumstaff.schema.ts:7-12) andemploymentType(staff.schema.ts:14-19); search box maps toqparam which the server accepts but ignores for staff (staff.service.ts:64-76, OQ-2) — label "filtering loaded results". - Pagination: page/limit controls (shared contract 00-shared/07 §5); infinite
scroll on phone
(proposed);metadrives controls (pagination-query.dto.ts:41-55). - Actions: FAB "Add staff" (
staff.create), row menu: View, Edit, Deactivate (staff.delete). - Empty: "No staff yet" + CTA; with filters: "No staff match filters".
S2 — Staff Detail (/staff/:id)
- Data:
GET /staff/:id(staff.controller.ts:30-31); 404 → not-found empty-state (staff.service.ts:58-62). - Header: avatar, employee number, name (joined from users
(proposed)), status badge, edit + deactivate actions. - Tabs
(proposed): Overview | Employment | Metadata | Audit (see 06). - Related info: department and designation resolve via
GET /departments/:id,GET /designations/:id— the detail payload does not embed them (staff.schema.ts:29-33; repositories store refs only).
S3 — Create Staff (/staff/new)
- Data:
POST /staff(staff.controller.ts:24-25). - Fields (exact from
create-staff.dto.ts:4-38):userId(required picker),employeeNumber(required),departmentId(optional picker),designationId(optional picker),employmentType(optional dropdown —full_time/part_time/ contract/intern),joiningDate(optional date),salaryGrade(optional text). - Server defaults:
status=active,employmentType=full_time(staff.service.ts:37-43) — surface as helper text, not editable. - Errors: 409 on duplicate employee number → inline field error
(
staff.service.ts:32-36); 400 validation → per-field (http-exception.filter.ts:103-107).
S4 — Edit Staff (/staff/:id/edit)
- Data:
PATCH /staff/:id(staff.controller.ts:33-34); prefilled fromGET /staff/:id. - Extra field vs create:
status(update-staff.dto.ts:35-38) andmetadata(update-staff.dto.ts:40-42) — the only sanctioned way to change lifecycle state. - Send only changed fields; audit delta = changed keys (
staff.service.ts:88).
S5 — Deactivate Flow (modal, from S1/S2)
- Data:
DELETE /staff/:id(staff.controller.ts:36-37). - Confirm dialog: warning that deactivation is permanent (soft-delete, no
restore endpoint —
base.repository.ts:68-74; OQ-8). 404 handled as already-gone.
S6 — Department List (/departments)
- Data:
GET /departments?page=&limit=(department.controller.ts:29-30). - Row: name, code badge, head (joined
headId(proposed)), status. - Actions: FAB "Add department", row menu Edit / Deactivate.
- Sort: server returns insertion order (
department.service.ts:38-50); sort by name is client-side(proposed).
S7 — Department Detail (/departments/:id)
- Data:
GET /departments/:id(department.controller.ts:32-33); 404 handled. - Sections: name/code/head/status (schema
department.schema.ts:9-19), member list(proposed)— computed client-side by fetching staff and filteringdepartmentId(no server endpoint returns members). - Head row: avatar + name via
headId→GET /staff/:id(proposed); deleted head → "—".
S8 — Department Form (create + edit)
- Data:
POST /departments/PATCH /departments/:id(department.controller.ts:26-27,35-40). - Fields (exact from
department.dto.ts:4-39):name(required),code(optional),headId(optional staff picker); edit addsstatus. - 409 duplicate name → inline (
department.service.ts:23-25).
S9 — Designation List (/designations)
- Data:
GET /designations?page=&limit=(designation.controller.ts:29-30). - Row: name, level, department (joined
departmentId(proposed)), status. - Display sort: by
levelascending(proposed)— server returns insertion order (designation.service.ts:41-50).
S10 — Designation Detail (/designations/:id)
- Data:
GET /designations/:id(designation.controller.ts:32-33). - Sections: name/level/department/status (
designation.schema.ts:9-19); staff holding the designation(proposed)— client-filtered list.
S11 — Designation Form (create + edit)
- Data:
POST /designations/PATCH /designations/:id(designation.controller.ts:26-27,35-40). - Fields (exact from
designation.dto.ts:4-39):name(required),departmentId(optional),level(optional int, default0); edit addsstatus. - 409 duplicate name → inline (
designation.service.ts:25-28).
Cross-screen state rules (shared 00-shared/06 §3.1)
Every screen: Initial/Loading → AppSkeleton; Error → AppErrorState(code, retry); Success+empty → AppEmptyState; Success → content. Pull-to-refresh on all lists.
409 → inline duplicate message (00-shared/06 §5); 401 → refresh flow; 403 →
permission screen/action hiding; 429 → backoff copy.
06 — Screen Specifications (Staff Module)
- SS1 — Staff List (
/staff) - SS2 — Staff Detail (
/staff/:id) - SS3 — Create Staff (
/staff/new) - SS4 — Edit Staff (
/staff/:id/edit) - SS5 — Deactivate Confirmation (modal)
- SS6 — Department List (
/departments) - SS7 — Department Detail (
/departments/:id) - SS8 — Department Form (create + edit)
- SS9 — Designation List (
/designations) - SS10 — Designation Detail (
/designations/:id) - SS11 — Designation Form (create + edit)
- Global behaviors (all screens)
Production-grade specs for the screens in 05_Screen_Inventory.md. Every field and behavior derives from backend DTOs/schemas (
src/modules/staff/**); layout/copy follow shared tokens (00-shared/02, 03, 04, 09). Screens are(planned); backend behavior is cited exactly.
SS1 — Staff List (/staff)
Layout (responsive)
Phone (<600dp) Tablet/Desktop (≥840dp)
┌────────────────────────────┐ ┌─────────────────┬──────────────────────┐
│ AppBar: "Staff" [search] │ │ AppBar "Staff" │ (master-detail) │
│ SearchBar (debounce 300ms) │ ├─────────────────┤ Detail pane (SS2) │
│ [Status ▾][Type ▾] chips │ │ SearchBar │ │
│ ┌────────────────────────┐ │ │ [Status ▾][Type ▾] │
│ │avatar EMP-0042 [▾] │ │ │ Row1 EMP-0042 ▸ (selected) │
│ │ Accounts • Acct │ │ │ Row2..N │
│ │ [active] │ │ │ [page controls] │
│ ├────────────────────────┤ │ └────────────────────────────────────────┘
│ │avatar EMP-0043 [▾] │ │
│ └────────────────────────┘ │
│ [infinite scroll spinner] │
│ FAB: "Add staff" │
└────────────────────────────┘
- List rows: height ≥ 56 (
00-shared/03 AppListTile); leading avatar 40 (AppAvatarinitials from joined user name — staff profile has no name field,staff.schema.ts:22-52); titleemployeeNumberinmono; subtitledepartment • designation(or "—"); trailingstatusbadge + overflow menu. - Master-detail at ≥ 840 dp (
00-shared/05 §3): selected row highlights, detail in right pane; phone pushes detail.
Data & pagination
GET /staff?page=1&limit=20(staff.controller.ts:27-28); server computesmeta(staff.service.ts:64-76,pagination-query.dto.ts:41-55).limitclamp 1–100,page≥ 1 (pagination-query.dto.ts:6-19); invalid → 400VALIDATION_ERROR(http-exception.filter.ts:28,103-107).qandsortaccepted but ignored byStaffService.find(staff.service.ts:64-76) — the search box and sort menu are client-side(proposed), labeled "filtering loaded results" (OQ-2).- Infinite scroll on phone (append page while
meta.hasNext); page controls on desktop (pagination-query.dto.ts:51-53); pull-to-refresh resets to page 1.
States
| State | Render |
|---|---|
| Initial/Loading | AppSkeleton list (8 rows) |
| Success + data | rows; "end of list" footer at last page |
| Success + empty (no filters) | AppEmptyState "No staff yet" + CTA "Add staff" |
| Success + empty (filters) | AppEmptyState "No staff match the current filters" |
| Error | AppErrorState(code, retry): 429 backoff copy; 5xx generic + requestId (00-shared/06 §5) |
Filter chips
- Status chip options map exactly to
StaffStatus:active, inactive, on_leave, terminated(staff.schema.ts:7-12). - Type chip options map exactly to
EmploymentType:full_time, part_time, contract, intern(staff.schema.ts:14-19). - Both are client-side filters over loaded pages (server has no such query params —
staff.service.ts:64-76).
Row menu
View (→ SS2), Edit (→ SS4, staff.update), Deactivate (→ SS5, staff.delete).
Destructive item styled error (00-shared/03 AppMenu).
A11y
AppSearchBar results live region; status announced as "badge {label}"; avatar
semantics = person name; Semantics on list position "item 3 of 12"
(00-shared/09 §5,7).
SS2 — Staff Detail (/staff/:id)
Layout
┌────────────────────────────────────────────┐
│ AppBar: back "Staff" [edit] [▾ more] │
│ ┌────────────────────────────────────────┐ │
│ │ (avatar 64) Name (from Users) │ │
│ │ EMP-0042 [active badge] │ │
│ │ Accounts • Accountant │ │
│ └────────────────────────────────────────┘ │
│ [Overview] [Employment] [Metadata] [Audit] │
│ ┌────────────────────────────────────────┐ │
│ │ Overview tab: │ │
│ │ Employee number EMP-0042 (mono) │ │
│ │ Department Accounts → link │ │
│ │ Designation Accountant → link │ │
│ │ Status active │ │
│ │ Employment tab: │ │
│ │ Employment type full_time │ │
│ │ Joining date 12 Mar 2025 │ │
│ │ Salary grade Grade B │ │
│ │ Metadata tab: free-form key/value │ │
│ │ Audit tab: version, createdAt, updatedAt│ │
│ └────────────────────────────────────────┘ │
└────────────────────────────────────────────┘
Data
GET /staff/:id(staff.controller.ts:30-31); missing → 404RESOURCE_NOT_FOUND(staff.service.ts:58-62) → not-found empty-state.- Ref fields are bare ObjectIds (
staff.schema.ts:29-33) — resolve department and designation by fetchingGET /departments/:id,GET /designations/:id(proposed). A ref that 404s (soft-deleted catalog entry) renders "—". - Joined user name:
GET /users/:idor a client-side join from the users list(proposed)— no staff endpoint returns the person's name.
Tabs
| Tab | Content source |
|---|---|
| Overview | employeeNumber, departmentId, designationId, status (staff.schema.ts:22-49) |
| Employment | employmentType, joiningDate, salaryGrade (staff.schema.ts:35-47) |
| Metadata | metadata key/values (staff.schema.ts:51-52); read-only; empty → "No metadata" |
| Audit | version, createdAt, updatedAt (base.schema.ts:29-34) — plain text; audit events live in the audit-write queue (event-queue-map.ts:35-36), not exposed by this module |
Tab selection is client-side (proposed); keep-alive via AppTabs
(00-shared/03 AppTabs).
Actions
- Edit → SS4 (
staff.update). - Deactivate → SS5 confirm (
staff.delete). - Copy employee number (context: shared elsewhere)
(proposed).
SS3 — Create Staff (/staff/new)
Full form spec in 08_Form_Specifications.md §F1. Summary:
- Two-step feel
(proposed): step 1 pick the User (requireduserId,create-staff.dto.ts:5-7) from the users directory; step 2 employment details. Or single screen with a user picker sheet — product choice; fields are the same. employeeNumberrequired, unique per tenant (create-staff.dto.ts:9-11,staff.schema.ts:57); 409 → inline error on the field (staff.service.ts:32-36).- On submit:
POST /staff→ 201; navigate to detail (SS2); snackbar "Staff created" (staff.service.ts:37-55;00-shared/03 AppSnackbar). - Helper copy: "Profile will be created as active" (
staff.service.ts:39) and "Employment type defaults to full-time" (staff.service.ts:40-41) — neither is editable at creation via the DTO (OQ-9 for a future create-status field).
SS4 — Edit Staff (/staff/:id/edit)
- Prefill from
GET /staff/:id(SS2 data). SubmitPATCH /staff/:idwith only changed fields (update-staff.dto.ts:5-42;staff.service.ts:78-91). - Fields:
employeeNumber,departmentId,designationId,employmentType,joiningDate,salaryGrade,status(dropdown with the 4 enum values,staff.schema.ts:7-12),metadata(key/value editor(proposed)). - Changing
statushere is the deactivation alternative foron_leave/inactivewithout deleting (update-staff.dto.ts:35-38). - 409 duplicate employee number → inline; 404 → not-found state (concurrent
deletion); success → snackbar "Saved" +
StaffUpdatedaudit event (staff.service.ts:82-90).
SS5 — Deactivate Confirmation (modal)
- Trigger: row menu or detail action ("Deactivate"). Requires
staff.delete. AppDialog: title "Deactivate {employee number}?", body: "This removes the staff member from all lists. There is no undo." — accurate: soft-delete excludes them from every query (base.repository.ts:20-30) and no restore endpoint exists (base.repository.ts:68-74; OQ-8).- Confirm →
DELETE /staff/:id→ success: pop back to list + snackbar "Staff deactivated"; 404 → snackbar "Already deactivated" + refresh list (staff.service.ts:93-104). - Destructive action styled
error, hapticheavyImpacton confirm (00-shared/08 §3); focus trap + ESC (00-shared/09 §6).
SS6 — Department List (/departments)
┌──────────────────────────────────────┐
│ AppBar: "Departments" [search] │
│ SearchBar (client-side) │
│ ┌──────────────────────────────────┐ │
│ │ Administration [ADMIN] [▾] │ │
│ │ Head: A. Sharma [active] │ │
│ ├──────────────────────────────────┤ │
│ │ Accounts [ACCT] [▾] │ │
│ │ Head: — [active] │ │
│ └──────────────────────────────────┘ │
│ FAB: "Add department" │
└──────────────────────────────────────┘
GET /departments?page=&limit=(department.controller.ts:29-30);metaserver-computed (department.service.ts:38-50). Server returns insertion order — client sorts by name(proposed).- Row: name (title),
codechip (optional,department.schema.ts:12-13), head name (resolved viaheadId(proposed); "—" when unset or deleteddepartment.schema.ts:15-16), status badge. - Row menu: Edit, Deactivate (both
department.manage). FAB "Add department" → SS8 create. - Empty: "No departments yet" + CTA. Member counts are client-computed
(proposed)(no server endpoint; OQ-4).
SS7 — Department Detail (/departments/:id)
GET /departments/:id(department.controller.ts:32-33); 404 → not-found.- Header card: name, code, status; head row (avatar + name) with "Change" action
(→ SS8 edit,
headIdfield). - "Members" section
(proposed): fetch staff list, filterdepartmentId === :idclient-side (the API has no members endpoint); rows link to SS2; empty → "No staff in this department". - Actions: Edit, Deactivate.
SS8 — Department Form (create + edit)
- Fields (exact):
namerequired text;codeoptional text (trimmed server-side,department.schema.ts:12-13);headIdoptional staff picker (department.dto.ts:4-18). Edit addsstatus(department.dto.ts:36-39). - Submit →
POST /departments(201 → list) orPATCH /departments/:id(200 → detail refresh). 409 duplicate name → inline error onname(department.service.ts:23-25). 404 on edit (deleted concurrently) → not-found. - Success snackbar: "Department created"/"Department saved". No events fire
(department service emits none —
department.service.ts:19-20, OQ-10).
SS9 — Designation List (/designations)
GET /designations?page=&limit=(designation.controller.ts:29-30).- Row: name (title),
level(mono,designation.schema.ts:15-16), department name (joined fromdepartmentId,(proposed), "—" if unset), status badge. - Display order:
levelascending, then name(proposed)— server returns insertion order (designation.service.ts:41-50). - Row menu: Edit, Deactivate. FAB "Add designation" → SS11 create.
- Empty: "No designations yet" + CTA.
SS10 — Designation Detail (/designations/:id)
GET /designations/:id(designation.controller.ts:32-33); 404 → not-found.- Header card: name, level, linked department (link → SS7), status.
- "Staff with this designation"
(proposed): client-filtered staff list; empty → "No staff with this designation". - Actions: Edit, Deactivate.
SS11 — Designation Form (create + edit)
- Fields (exact):
namerequired text;departmentIdoptional department picker;leveloptional integer (IsInt—designation.dto.ts:14-17), default0shown in helper (designation.schema.ts:15-16); edit addsstatus(designation.dto.ts:36-39). - Submit →
POST /designations/PATCH /designations/:id. 409 duplicate name → inline (designation.service.ts:25-28). Non-integerlevel→ 400VALIDATION_ERRORwith details (http-exception.filter.ts:103-107). - No events emitted on create/update/delete (OQ-10).
Global behaviors (all screens)
- Permissions: render/route only when the client role has the required perm
(map:
permissions.constants.ts:19-24); server RBAC not yet enforced on these endpoints (staff.controller.ts:21) — double-gate client-side, flag OQ-1. - Offline: reads from last-good cache + offline banner (
00-shared/06 §3.7); writes blocked with guidance (no module offline queue). - Loading:
AppSkeleton≤ 200 ms; content ≤ 2 s (00-shared/10 §1). - Session: 401 → single-flight refresh; failure → re-login preserving nav
(
00-shared/06 §3.6).
07 — Component Library (Staff Module)
- 1. Shared components in use (by screen)
- 2. Module-specific components
- 3. Composition rules
- 4. Golden tests
Module-specific components. Shared components are referenced by name from 00-shared/03_Component_Library.md; this file adds staff-specific widgets only. All tokens per 00-shared/02.
1. Shared components in use (by screen)
| Shared component | Screens |
|---|---|
AppSkeleton | S1, S6, S9 lists |
AppEmptyState | S1, S2 (404), S6, S7, S9, S10 |
AppErrorState | all (per error code) |
AppSearchBar | S1, S6, S9 (client-side filtering — server ignores q for staff, OQ-2) |
AppChips (FilterChip) | S1 status/type filters |
AppListTile | S1, S6, S7, S9, S10 rows |
AppAvatar | S1 rows, S2 header, S7 head |
AppBadge | status badges (S1, S2, S6, S9) |
AppTabs | S2 detail tabs |
AppFAB | S1, S6, S9 ("Add staff/department/designation") |
AppButton | forms, dialogs |
AppDialog | S5 deactivate confirm; delete confirms in catalogs |
AppMenu | row overflow menus |
AppSnackbar | create/update/deactivate feedback |
AppDropdown | department/designation/employment-type pickers |
AppDatePicker | joiningDate |
AppInfoRow | S2 detail fields |
AppSectionHeader | S7/S10 sections |
AppProgress | submit/pending states |
2. Module-specific components
StaffStatusBadge
- Props:
status: StaffStatus,size?. - Behavior: maps 1:1 to the enum (
staff.schema.ts:7-12):active→ success (secondary),inactive→ neutral (outline),on_leave→ tertiary/warning,terminated→ error. - A11y: icon + label + color — never color-only (00-shared/09 §9).
- Flutter:
AppBadgewithSemantics(label: 'Status: on leave').
EmploymentTypeLabel
- Props:
type: EmploymentType. - Behavior: renders humanized label for
full_time, part_time, contract, intern(staff.schema.ts:14-19), e.g. "Full-time". Localized; unknown value falls back to the raw string (server DTO allows any string — noIsEnumoncreate-staff.dto.ts:26-28, OQ-9). - Flutter: simple
Textwith a const map.
EmployeeNumberText
- Props:
employeeNumber: String. - Behavior:
monoface +FontFeature.tabularFigures()(00-shared/02 §2), copy affordance(proposed). - Flutter:
TextwithfontFamilyFallbackmono +SelectableText.
RefChip (department/designation ref)
- Props:
label,href. - Behavior: navigation chip for a resolved
departmentId/designationId; renders "—" when the ref is unset or unresolvable (soft-deleted catalog entry → 404 on lookup,base.repository.ts:20-30). - Flutter:
ActionChip+ null-state Text.
CatalogPickerSheet (used in S3, S4, S8, S11)
- Props:
endpoint(departments | designations),value,onPicked,allowClear. - Behavior:
AppBottomSheetlist backed byGET /departmentsorGET /designations(pagination + client search); onlystatus: activeentries offered(proposed); "Unassigned" clear option; empty state → "No departments yet — create one". - Flutter:
showModalBottomSheet+ListView.builder.
HeadPicker (S8)
- Props:
value,onPicked. - Behavior: staff picker limited to
status: active((proposed)filter over staff list; backend list returns all —staff.service.ts:64-76); shows employee number + name. - Flutter: sheet with
SearchBar+ListTiles.
MetadataEditor (S4 metadata field)
- Props:
value: Map<String, Object?>,onChanged. - Behavior: editable key/value rows (add/remove) bound to the free-form
metadataobject (staff.schema.ts:51-52,update-staff.dto.ts:40-42); values serialized as JSON strings client-side(proposed). - Flutter:
ListViewofRow(key TextField, value TextField, remove IconButton)- "Add field"
TextButton.
- "Add field"
3. Composition rules
- List screens:
AppScaffold+RefreshIndicator+ListView.builder+ footer (spinner / end-of-list) (00-shared/03 §F). - Detail: header
AppCard+AppTabs; tab content inTabBarViewwith keep-alive. - Forms: one screen, fields in
FormwithAppTextField/AppDropdown/AppDatePicker(08_Form_Specifications.md); submitAppButton(filled, loading:).
4. Golden tests
New components get goldens at 3 sizes + dark mode (00-shared/03 §G):
StaffStatusBadge (4 states), EmploymentTypeLabel (4 values), RefChip
(resolved/unset), CatalogPickerSheet (data/empty/error).
08 — Form Specifications (Staff Module)
- F1 — Create Staff (
/staff/new) →POST /staff - F2 — Edit Staff (
/staff/:id/edit) →PATCH /staff/:id - F3 — Create Department (
/departments/new) →POST /departments - F4 — Edit Department (
/departments/:id/edit) →PATCH /departments/:id - F5 — Create Designation (
/designations/new) →POST /designations - F6 — Edit Designation (
/designations/:id/edit) →PATCH /designations/:id - Cross-form rules
Field-by-field specs for every create/update form. DTO columns cite the exact validator source. Backend note: all three modules validate via
class-validator; failures return 400VALIDATION_ERRORwitherror.details[](http-exception.filter.ts:28,103-107), mapped to field errors by the client (00-shared/06 §5).
F1 — Create Staff (/staff/new) → POST /staff
Source: create-staff.dto.ts:4-38; staff.controller.ts:24-25.
| # | Field | Control | Required | Validation (source) | Server behavior |
|---|---|---|---|---|---|
| 1 | userId | User picker (sheet, searchable) | Yes | @IsMongoId() (create-staff.dto.ts:5-7) | Stored as ref to User (staff.schema.ts:23-24) |
| 2 | employeeNumber | AppTextField, mono, maxLength 64 (proposed) | Yes | @IsString() (create-staff.dto.ts:9-11) | Trimmed (staff.schema.ts:26-27); unique per tenant — 409 on dup (staff.service.ts:31-36, staff.schema.ts:57) |
| 3 | departmentId | CatalogPickerSheet (departments) | No | @IsOptional() @IsMongoId() (create-staff.dto.ts:13-16) | Ref to Department (staff.schema.ts:29-30) |
| 4 | designationId | CatalogPickerSheet (designations) | No | @IsOptional() @IsMongoId() (create-staff.dto.ts:18-21) | Ref to Designation (staff.schema.ts:32-33) |
| 5 | employmentType | AppDropdown: full_time, part_time, contract, intern | No | @IsOptional() @IsString() (create-staff.dto.ts:23-28) — no @IsEnum; enum is documentation only (staff.schema.ts:14-19) | Defaults to full_time when absent (staff.service.ts:40-41) |
| 6 | joiningDate | AppDatePicker | No | @IsOptional() @IsDateString() (create-staff.dto.ts:30-33) | Converted to Date (staff.service.ts:42) |
| 7 | salaryGrade | AppTextField (free text) | No | @IsOptional() @IsString() (create-staff.dto.ts:35-38) | Trimmed; reference only, not payroll (staff.schema.ts:45-46, Staff.md:60) |
- Not in this form:
status(alwaysactiveon create —staff.service.ts:39) andmetadata(update-only,update-staff.dto.ts:40-42). - Helper texts: "Created as active"; "Defaults to full-time".
- Submit: button
loading:while pending, anti-double-submit (00-shared/08 §6); success → snackbar + navigate to detail (SS2).
F2 — Edit Staff (/staff/:id/edit) → PATCH /staff/:id
Source: update-staff.dto.ts:4-42; all fields optional; prefilled from GET /staff/:id.
Send only changed fields (staff.service.ts:78-91 — changes = audit delta, line 88).
| # | Field | Control | Required | Validation (source) | Notes |
|---|---|---|---|---|---|
| 1 | employeeNumber | AppTextField | No | @IsOptional() @IsString() (update-staff.dto.ts:5-8) | 409 on duplicate (staff.service.ts:31-36 shared check only on create — rename dup-check caveat OQ-9) |
| 2 | departmentId | CatalogPickerSheet | No | @IsOptional() @IsMongoId() (lines 10-13) | Clearing sends null? — see OQ-11 |
| 3 | designationId | CatalogPickerSheet | No | @IsOptional() @IsMongoId() (lines 15-18) | same |
| 4 | employmentType | AppDropdown (4 values) | No | @IsOptional() @IsString() (lines 20-23) | no enum validation |
| 5 | joiningDate | AppDatePicker | No | @IsOptional() @IsDateString() (lines 25-28) | |
| 6 | salaryGrade | AppTextField | No | @IsOptional() @IsString() (lines 30-33) | |
| 7 | status | AppDropdown: active, inactive, on_leave, terminated | No | @IsOptional() @IsString() (lines 35-38) | lifecycle enum staff.schema.ts:7-12; the sanctioned way to change lifecycle state |
| 8 | metadata | MetadataEditor (key/value) | No | @IsOptional() (lines 40-42) | free-form object (staff.schema.ts:51-52) |
F3 — Create Department (/departments/new) → POST /departments
Source: department.dto.ts:4-18; department.controller.ts:26-27.
| # | Field | Control | Required | Validation (source) | Server behavior |
|---|---|---|---|---|---|
| 1 | name | AppTextField | Yes | @IsString() (department.dto.ts:5-7) | Trimmed (department.schema.ts:9-10); unique per tenant — 409 on dup (department.service.ts:23-25, department.schema.ts:24) |
| 2 | code | AppTextField (short, uppercase hint) | No | @IsOptional() @IsString() (department.dto.ts:9-12) | Trimmed (department.schema.ts:12-13) |
| 3 | headId | HeadPicker (active staff) | No | @IsOptional() @IsMongoId() (department.dto.ts:14-17) | Ref to Staff (department.schema.ts:15-16) |
- Server sets
status: 'active'on create (department.service.ts:26-29). - Success → snackbar + navigate to detail (SS7); 409 inline on
name.
F4 — Edit Department (/departments/:id/edit) → PATCH /departments/:id
Source: department.dto.ts:20-39; all optional.
| # | Field | Control | Validation (source) | Notes |
|---|---|---|---|---|
| 1 | name | AppTextField | @IsOptional() @IsString() (lines 22-24) | 409 on duplicate name (department.service.ts:23-25 — check runs on create; rename guard applies, verify OQ-9) |
| 2 | code | AppTextField | @IsOptional() @IsString() (lines 26-29) | |
| 3 | headId | HeadPicker | @IsOptional() @IsMongoId() (lines 31-34) | unset → clear |
| 4 | status | AppDropdown (active/other) | @IsOptional() @IsString() (lines 36-39) | catalog soft-state; no enum (department.schema.ts:18-19 is a plain string) |
F5 — Create Designation (/designations/new) → POST /designations
Source: designation.dto.ts:4-18; designation.controller.ts:26-27.
| # | Field | Control | Required | Validation (source) | Server behavior |
|---|---|---|---|---|---|
| 1 | departmentId | CatalogPickerSheet (departments) | No | @IsOptional() @IsMongoId() (designation.dto.ts:5-8) | Ref to Department (designation.schema.ts:9-10) — reference only, not enforced (OQ-3) |
| 2 | name | AppTextField | Yes | @IsString() (designation.dto.ts:10-12) | Trimmed (designation.schema.ts:12-13); unique per tenant — 409 (designation.service.ts:25-28, designation.schema.ts:24) |
| 3 | level | AppTextField (number) or AppStepper | No | @IsOptional() @IsInt() (designation.dto.ts:14-17) | Defaults to 0 (designation.schema.ts:15-16); non-int → 400 details |
- Server sets
status: 'active'on create (designation.service.ts:29-32).
F6 — Edit Designation (/designations/:id/edit) → PATCH /designations/:id
Source: designation.dto.ts:20-39; all optional: departmentId (22-24), name
(26-29), level (@IsInt(), 31-34), status (36-39).
Cross-form rules
- Errors: first invalid field receives focus on submit; errors clear on edit;
error announced via
Semantics(00-shared/09 §10, 00-shared/03 AppTextField). - 409
DUPLICATE_RESOURCE→ inline conflict message + suggest search/refresh (00-shared/06 §5). - 404 on save (concurrent deletion) → not-found state, do not resubmit.
- 429 → "Too many requests — retry in Ns" + backoff, no auto-retry (00-shared/07 §4).
- Autofill hints on
employeeNumber(autofillHints: 'organization-identifier'(proposed));userIdpicker searchable. - Haptics: success
lightImpact, errormediumImpact(00-shared/08 §3).
09 — User Behaviour (Staff Module)
- 1. Browse & search behaviour
- 2. Create behaviours
- 3. Edit behaviours
- 4. Deactivate behaviour (destructive)
- 5. Catalog behaviours (departments/designations)
- 6. Status & error behaviours (all screens)
- 7. Analytics events
(proposed)(00-shared/10 §8 naming)
Behavioral rules for staff screens.
(proposed)= client decision; backend-derived items cite source.
1. Browse & search behaviour
- HR opens Staff → list loads from
GET /staff(page 1, limit 20 —staff.controller.ts:27-28,pagination-query.dto.ts:13-19). Skeleton ≤ 200 ms (00-shared/10 §1). - Search box: client-side filter with 300 ms debounce (00-shared/03 AppSearchBar);
the
qparam is sent but ignored server-side for staff (staff.service.ts:64-76, OQ-2) — UI labels "filtering loaded results" when a filter is active. - Status/type chips filter loaded pages only; switching chips keeps scroll position.
- Scroll down hides FAB, scroll up restores (00-shared/03 AppFAB); infinite scroll
appends while
meta.hasNext(pagination-query.dto.ts:52). - Pull-to-refresh resets to page 1 and bypasses cache (00-shared/06 §3.3).
- Rows are tappable → detail; overflow menu offers View/Edit/Deactivate.
2. Create behaviours
- "Add staff" FAB (
staff.create) → SS3. User picker lists users (required —create-staff.dto.ts:5-7); a staff profile cannot exist without a user. - Submitting with a duplicate
employeeNumber→ 409 inline on the field (staff.service.ts:31-36); user edits and resubmits — no navigation loss. - On success: navigate to detail + snackbar;
StaffCreatedfans out in-app (event-queue-map.ts:34). Never claim an email was sent — the emails worker has noStaffCreatedhandler (email.worker.ts:25-42, OQ-6).
3. Edit behaviours
- Edit prefills from detail; only changed fields sent (
staff.service.ts:88). - Status dropdown exposes the full enum (
staff.schema.ts:7-12): moving someone toinactive/on_leavekeeps the record (unlike deactivate). - Metadata editor: additive key/values; empty object omitted from the PATCH
(proposed). - Concurrent-edit awareness:
versionexists server-side (base.repository.ts:63, 71;base.schema.ts:29-31) but is not in the DTO (update-staff.dto.ts) — last-write-wins; client does not implement optimistic conflict UI (OQ-12).
4. Deactivate behaviour (destructive)
- Trigger anywhere → SS5 confirm dialog; copy is honest: "removes from all lists,
no undo" (
base.repository.ts:68-74). - Confirm →
DELETE→ success snackbar + list refresh (row gone). 404 → "already deactivated" + refresh. - No restore flow — if the org misclicks, the record is gone from the UI
(data survives in DB with
isDeleted: true). HR must recreate (and the oldemployeeNumberremains blocked by the unique indexstaff.schema.ts:57). - Rejected path: user without
staff.deletesees no deactivate action (client mirror ofpermissions.constants.ts:22).
5. Catalog behaviours (departments/designations)
- Add/rename/recode/head assignment: same form rules as F3–F6; duplicate name →
409 inline (
department.service.ts:23-25,designation.service.ts:25-28). - Delete confirm: warns "existing staff references will dangle" — no server
guard blocks deleting a department with members (OQ-4); UI offers no member
reassignment step (
(proposed)mitigation: show member count in the dialog). - Catalog changes do not produce notifications (no events emitted —
department.service.ts:19-20,designation.service.ts:22-23; OQ-10). - Sorting designations by
leveland departments by name: client-side(proposed).
6. Status & error behaviours (all screens)
AppErrorStateper code (00-shared/06 §5): 401 refresh; 403 permission screen/action hide; 404 empty-state; 400 field errors; 409 inline; 422 n/a (no 422 producers in this module); 429 backoff; 5xx generic + requestId.- Offline: cached list +
AppOfflineBanner; writes blocked with guidance (00-shared/06 §3.7). - Session expiry mid-flow: re-login preserving nav (00-shared/06 §3.6).
- Optimistic updates: none in this module — all mutations wait for the server result (status changes included; safe per 00-shared/06 §3.5).
7. Analytics events (proposed) (00-shared/10 §8 naming)
staff.list.view, staff.list.search, staff.list.filter(status|type),
staff.detail.view(id), staff.create.submit|success|duplicate,
staff.update.submit|success, staff.deactivate.confirm|success,
department.create.success|duplicate, department.delete.confirm,
designation.create.success|duplicate, designation.delete.confirm.
10 — Interaction Specification (Staff Module)
- 1. Gestures & inputs
- 2. Motion language
- 3. Haptics
- 4. Frustration controls
- 5. Keyboard & desktop
- 6. Modal hierarchy (shared rule applied)
Module specialization of 00-shared/08_Interaction_&_Motion.md.
1. Gestures & inputs
| Gesture | Behavior | Screen |
|---|---|---|
| Tap row | Open detail (SS2); ripple m-fast | S1, S6, S9 |
| Tap FAB | "Add staff / department / designation" — scale-in m-entrance | S1, S6, S9 |
| Tap row menu (⋮) | AppMenu: View / Edit / Deactivate (destructive in error) | S1, S6, S9 |
| Long-press row | Same menu (phone); haptic selectionClick | S1, S6, S9 |
| Pull down | Refresh (reset page 1, bypass cache); spinner primary; lightImpact on completion | S1, S6, S9 |
Swipe left on row (proposed) | Quick "Edit" only (deactivate stays in menu — destructive ops never swipe-first) | S1 |
| Back / back-gesture | Pop to list; on root tab → previous shell branch | all |
| ESC (desktop) | Close dialog/sheet/menu; cancel selection | all |
| Enter in form field | Next field; Ctrl/Cmd+Enter submits | S3, S4, S8, S11 |
2. Motion language
| Interaction | Motion | Token |
|---|---|---|
| List → detail push | Slide forward + content fade/rise | m-base / m-slow (00-shared/08 §4) |
| Avatar list → detail header | Hero flight (shared element) | m-slow |
| List rows on first load | Entrance stagger 40 ms | m-entrance |
| Status badge change | Color/size cross-fade; icon morph via AnimatedSwitcher | m-fast |
| Skeleton → content | Cross-fade, no pop | m-slow |
| Dialog (deactivate) | Scale + fade in; slide down on dismiss | m-base |
| FAB hide/show on scroll | Fade + slide | m-entrance |
| Filter chip select | Ink ripple + pill slide | m-fast |
| Infinite scroll footer | Bottom spinner fade-in | m-instant |
| Submit pending | Spinner replaces label; success → brief check + snackbar | — |
Reduced motion (system setting): everything → fade ≤ m-instant; no stagger, no
shimmer loop, no hero (00-shared/08 §4).
3. Haptics
| Event | Haptic |
|---|---|
| Create/update success | lightImpact |
| Validation/API error | mediumImpact |
| Deactivate confirm | heavyImpact (destructive) |
| Menu open | selectionClick |
Off when system haptics disabled; never on web/desktop (00-shared/08 §3).
4. Frustration controls
- Every interactive element gives press feedback within 100 ms (00-shared/08 §6).
- Submit buttons: disabled while pending (anti-double-submit); 409/404 never silently swallowed — inline or snackbar.
- Network-bound actions always show pending state (spinner in button, skeleton in lists) — never a silent wait.
- Long lists: keep scroll position across filter toggles; restore position after detail pop (master-detail preserves state per shell branch — 00-shared/05 §3).
5. Keyboard & desktop
- Tab order: search → chips → rows → FAB; visible focus ring
primary(00-shared/09 §6). Ctrl+Kglobal search (00-shared/05 §7);Non list screens → create(proposed); ESC closes dialogs/menus.- Right-click row = same menu as long-press (00-shared/08 §2).
- Breadcrumbs on desktop at depth > 2:
Staff / Departments / Administration(00-shared/05 §3).
6. Modal hierarchy (shared rule applied)
Snackbar > bottom sheet (pickers, filters) > dialog (deactivate confirm) > full-screen page (create/edit forms) (00-shared/05 §5). Deactivate is a dialog — not a sheet — because it is destructive and needs explicit confirm.
11 — Design System Mapping (Staff Module)
- 1. Module surfaces
- 2. Typography mapping
- 3. Iconography
- 4. Layout tokens
- 5. Status visual grammar (staff-specific)
- 6. Dark mode parity
- 7. Theming rules (enforced, shared)
Extends 00-shared/04_Design_System_Mapping.md with staff-specific widget → token mappings. Theme, typography, iconography, motion, layout: as shared; only module deltas listed.
1. Module surfaces
| Surface | Token | Notes |
|---|---|---|
| List rows / cards | surfaceContainerLow, e-0/e-1, r-lg | AppCard/AppListTile (00-shared/04 §2) |
| Detail header card | surfaceContainerHigh, e-1 | Hero source |
| Selected master-detail row | secondaryContainer highlight | 00-shared/04 §2 (NavigationRail pattern) |
| Status badge fill | active→secondaryContainer, inactive→surfaceVariant, on_leave→tertiaryContainer, terminated→errorContainer | icon+label, never color-only (00-shared/09 §9) |
| Filter chips | selected secondaryContainer, else surfaceVariant | 00-shared/04 §2 |
| FAB | primaryContainer, e-3, 56 dp | 00-shared/04 §2 |
| Deactivate dialog | surfaceContainerHigh, e-4, destructive action error/onError | 00-shared/03 AppDialog |
| Empty states | displaySmall title, bodyMedium onSurfaceVariant | 00-shared/04 §3 |
2. Typography mapping
| Element | Text token | Note |
|---|---|---|
| Screen title "Staff" | headlineMedium | one per screen (00-shared/09 §11) |
| Employee number | mono 14 + tabularFigures | IDs always mono (00-shared/02 §2) |
| Row title / section header | titleMedium | |
| Subtitle (dept • designation) | bodyMedium onSurfaceVariant | |
| Status badge | labelMedium | |
| Form labels / helper | bodyMedium / bodySmall | |
| Dialog title | titleLarge |
3. Iconography
Material Symbols outlined (00-shared/02 §7): badge (staff), groups
(departments), workspace_premium (designations), search, add, edit,
delete, person_off (deactivate), today (joining date), payments/attach_money
(salary grade), schedule (employment type), account_tree (dept ref),
category (designation ref). Status icons filled variant. Every icon paired with
text label or tooltip (00-shared/02 §7).
4. Layout tokens
- List rows 56 dp (00-shared/02 §9); avatar 40 (list), 64 (detail header).
- Screen horizontal padding 16 (phone) / 24 (tablet+); card padding 16; section gap 24 (00-shared/04 §6).
- Form field gap 16; master-detail breakpoint 840 dp.
- FAB above bottom nav (phone); page controls right-aligned (desktop).
5. Status visual grammar (staff-specific)
active → [✓ icon] "Active" secondaryContainer pill
inactive → [– icon] "Inactive" outline pill
on_leave → [⏳ icon] "On leave" tertiaryContainer pill
terminated → [✕ icon] "Terminated" errorContainer pill
Enum source: staff.schema.ts:7-12. Same grammar reused by Teachers
(teacher.schema.ts:7-12).
6. Dark mode parity
Elevation substitutes for color on dark surfaces; badges keep
container-role colors at full contrast; no hardcoded Color — all tokens
(00-shared/04 §7).
7. Theming rules (enforced, shared)
- No literal colors/spacing/durations in widgets.
- Tenant branding overrides
primaryonly (00-shared/02 §1). - Dark mode first-class; goldens at light+dark, 3 sizes (
00-shared/03 §G).
12 — API Mapping (Staff Module)
- 1. Staff (
staff.controller.ts) - 2. Departments (
department.controller.ts) - 3. Designations (
designation.controller.ts) - 4. Error codes this module can produce
- 5. Envelope examples
- 6. Cross-module endpoints the client needs (joins
(proposed)) - 7. Query-param reality check
Exact endpoints from
src/modules/staff/controllers/**; envelopes per 00-shared/07_API_Conventions.md (exact wire contract fromresponse-envelope.interceptor.ts:11-62andhttp-exception.filter.ts:27-82). Base/api/v1; Bearer JWT;tenantIdfrom token only (client never sends it —base.repository.ts:32-36writes it server-side).
1. Staff (staff.controller.ts)
| # | Method | Path | Query/Body | Success | Errors (source) | Permission (intended) |
|---|---|---|---|---|---|---|
| E1 | POST | /staff | Body CreateStaffDto (create-staff.dto.ts:4-38) | 201 envelope data=Staff doc (staff.service.ts:37-55) | 400 validation; 409 dup employeeNumber (staff.service.ts:32-36); 401 | staff.create (permissions.constants.ts:20) |
| E2 | GET | /staff | page, limit (1–100, default 20), sort, q (pagination-query.dto.ts:5-30) | 200 data=Staff[], meta={page,limit,totalItems,totalPages,hasNext,hasPrevious} (staff.service.ts:64-76; pagination-query.dto.ts:32-55) | 400 (page/limit); 401 | staff.read (line 19) |
| E3 | GET | /staff/:id | — | 200 data=Staff doc | 404 RESOURCE_NOT_FOUND (staff.service.ts:58-62); 400 invalid id (CastError → http-exception.filter.ts:47-48,91-92); 401 | staff.read |
| E4 | PATCH | /staff/:id | Body UpdateStaffDto (update-staff.dto.ts:4-42) | 200 data=updated doc (staff.service.ts:78-91); StaffUpdated → audit-write (event-queue-map.ts:35) | 404 (line 79); 400; 401 | staff.update (line 21) |
| E5 | DELETE | /staff/:id | — | 200 data=empty (soft-delete: isDeleted/deletedAt/deletedBy + version++ — base.repository.ts:68-74); StaffDeleted → audit-write (event-queue-map.ts:36) | 404 (line 94-95); 401 | staff.delete (line 22) |
Staff doc shape (response data — staff.schema.ts:21-52 + base.schema.ts:8-35):
{
"_id": "…", "tenantId": "…",
"userId": "ObjectId", "employeeNumber": "STF001",
"departmentId": "ObjectId?", "designationId": "ObjectId?",
"employmentType": "full_time", "joiningDate": "ISO?", "salaryGrade": "…?",
"status": "active", "metadata": { },
"createdBy": "ObjectId?", "updatedBy": "ObjectId?",
"isDeleted": false, "deletedAt": null, "deletedBy": null, "version": 0,
"createdAt": "ISO", "updatedAt": "ISO"
}
2. Departments (department.controller.ts)
| # | Method | Path | Body | Success | Errors | Permission |
|---|---|---|---|---|---|---|
| E6 | POST | /departments | CreateDepartmentDto (department.dto.ts:4-18) | 201 data=doc, status:'active' (department.service.ts:22-30) | 400; 409 dup name (department.service.ts:23-25); 401 | department.manage (permissions.constants.ts:23) |
| E7 | GET | /departments | page, limit, sort, q | 200 data+meta (department.service.ts:38-50) | 400; 401 | department.manage |
| E8 | GET | /departments/:id | — | 200 data=doc | 404 (department.service.ts:32-36); 400; 401 | department.manage |
| E9 | PATCH | /departments/:id | UpdateDepartmentDto (lines 20-39) | 200 data=updated | 404 (line 57-58); 400; 401 | department.manage |
| E10 | DELETE | /departments/:id | — | 200 soft-delete (no member guard — OQ-4) | 404 (line 63-64); 401 | department.manage |
Department doc shape (department.schema.ts:7-19): name, code?,
headId? (ref Staff), status (string, default 'active') + BaseSchema fields.
No events on any department operation (department.service.ts:19-20).
3. Designations (designation.controller.ts)
| # | Method | Path | Body | Success | Errors | Permission |
|---|---|---|---|---|---|---|
| E11 | POST | /designations | CreateDesignationDto (designation.dto.ts:4-18) | 201 data=doc, status:'active', level default 0 (designation.service.ts:25-32; designation.schema.ts:15-16,18-19) | 400 (incl. level non-int); 409 dup name (designation.service.ts:25-28); 401 | designation.manage (permissions.constants.ts:24) |
| E12 | GET | /designations | page, limit, sort, q | 200 data+meta (designation.service.ts:41-50) | 400; 401 | designation.manage |
| E13 | GET | /designations/:id | — | 200 data=doc | 404 (designation.service.ts:35-39); 400; 401 | designation.manage |
| E14 | PATCH | /designations/:id | UpdateDesignationDto (lines 20-39) | 200 data=updated | 404 (line 60-61); 400; 401 | designation.manage |
| E15 | DELETE | /designations/:id | — | 200 soft-delete | 404 (line 66-67); 401 | designation.manage |
Designation doc shape (designation.schema.ts:7-19): departmentId? (ref
Department), name, level (int, default 0), status (string, default
'active') + BaseSchema fields. No events (designation.service.ts:22-23).
4. Error codes this module can produce
| HTTP | Code | Producer |
|---|---|---|
| 400 | VALIDATION_ERROR | DTO validators (all endpoints); CastError on :id (http-exception.filter.ts:47-48) |
| 401 | UNAUTHENTICATED | JwtAuthGuard missing/expired token (jwt-auth.guard.ts:36-59) |
| 403 | PERMISSION_DENIED | RbacGuard where applied — not yet wired on these controllers (staff.controller.ts:21; OQ-1) |
| 404 | RESOURCE_NOT_FOUND | missing staff/department/designation (staff.service.ts:60,81,95; department.service.ts:34,58,64; designation.service.ts:37,61,67) |
| 409 | DUPLICATE_RESOURCE | dup employeeNumber / dept name / designation name (staff.service.ts:32-36; department.service.ts:23-25; designation.service.ts:25-28); unique indexes (staff.schema.ts:57-58, department.schema.ts:24, designation.schema.ts:24) |
| 429 | RATE_LIMITED | platform rate limiter (auth 10/min, api 100/min, admin 500/min — 00-shared/07 §4) |
| 5xx | INTERNAL_SERVER_ERROR | unexpected (http-exception.filter.ts:50-55,60-65) |
422 BUSINESS_RULE_VIOLATION is defined by the filter (http-exception.filter.ts:33)
but no staff-module code path produces it.
5. Envelope examples
Success (paginated E2):
{ "success": true, "message": "OK",
"data": [ { "_id": "…", "employeeNumber": "STF001", "status": "active", … } ],
"meta": { "page": 1, "limit": 20, "totalItems": 3, "totalPages": 1,
"hasNext": false, "hasPrevious": false },
"timestamp": "…", "requestId": "…" }
Error (duplicate employee number E1):
{ "success": false,
"message": "Employee number \"STF001\" already exists.",
"error": { "code": "DUPLICATE_RESOURCE" },
"timestamp": "…", "requestId": "…" }
Source of 409 message: staff.service.ts:34-36 (ConflictException string);
error envelope: http-exception.filter.ts:73-81; DUPLICATE_RESOURCE mapping:
http-exception.filter.ts:32.
6. Cross-module endpoints the client needs (joins (proposed))
| Purpose | Endpoint | Source |
|---|---|---|
| User picker + name join (S3/S2) | GET /users, GET /users/:id | Users module |
| Head display (S7) | GET /staff/:id on headId | department.schema.ts:15-16 |
| Dept/designation name resolution (S2) | GET /departments/:id, GET /designations/:id | refs staff.schema.ts:29-33 |
| Member counts (S7/S10) | GET /staff filtered client-side | no server-side filter (staff.service.ts:64-76) |
7. Query-param reality check
sortandqare accepted byPaginationQueryDto(pagination-query.dto.ts:21-29) but unused by all three list services (staff.service.ts:64-76,department.service.ts:38-50,designation.service.ts:41-50) — server-side search/sort is(planned)(OQ-2). No filter params exist forstatus,employmentType,departmentId, ordesignationId.
13 — State Management (Staff Module)
- 1. Cubits
- 2. Async state machine (all cubits)
- 3. Pagination (mixin
PaginatedListMixin<T>) - 4. Caching & staleness
- 5. Optimistic updates
- 6. Cross-screen sync
- 7. Error handling (module specifics)
- 8. Testing hooks
Per-screen state on top of the shared architecture (00-shared/06_State_Management.md). Stack:
flutter_blocCubits,get_itDI, repository layer withdio.
1. Cubits
| Cubit | Screen | Events | State |
|---|---|---|---|
StaffListCubit | S1 | Load, Refresh, LoadMore, Retry, ChangeStatusFilter, ChangeTypeFilter, Search(q) | {loadState, items, page, hasNext, isLoadingMore, statusFilter, typeFilter, query, meta} |
StaffDetailCubit | S2 | Load(id), Retry | {loadState, staff?, department?, designation?, user?} |
StaffFormCubit | S3/S4 | LoadRefs, Load(initial), Submit(delta), Clear | {loadState, submitState, fields, users, departments, designations, fieldErrors} |
DepartmentListCubit | S6 | Load, Refresh, LoadMore, Retry | paginated + membersCountByDept (proposed) |
DepartmentFormCubit | S8 | Load(initial), Submit(delta) | form state |
DesignationListCubit | S9 | Load, Refresh, LoadMore, Retry | paginated |
DesignationFormCubit | S11 | Load(initial), Submit(delta) | form state |
DeactivateCubit | S5 | Confirm(id), Dismiss | {idle, submitting, success, error} |
Deactivate lives in its own cubit (not the list cubit) so the dialog state is independent and testable.
2. Async state machine (all cubits)
Initial → Loading → Success | Error(ApiException) (00-shared/06 §3.1); UI maps:
Loading → AppSkeleton, Error → AppErrorState(code, retry), Success+empty →
AppEmptyState, Success → content. Form cubits add submitState with
fieldErrors mapped from ApiException.details (400 → error.details[],
http-exception.filter.ts:103-107).
3. Pagination (mixin PaginatedListMixin<T>)
- Contract:
page/limit(20)/sort/q(00-shared/06 §3.2). loadMorefires whenmeta.hasNext(pagination-query.dto.ts:52) andisLoadingMore == false; appends, dedupes by_id.Refreshbypasses cache and resets to page 1.- Cache key:
sl:{tenant}:staff:list:{page}per module cache rules (00-shared/06 §3.3).
4. Caching & staleness
| Data | TTL (proposed) | Notes |
|---|---|---|
| Staff list pages | 5 min | volatile (status changes) |
| Department list | 24 h | reference catalog (00-shared/06 §3.3) |
| Designation list | 24 h | reference catalog |
| Staff detail | no client cache | refetch per visit; server caches |
| Users (picker) | 24 h | cross-module reference |
Stale-while-revalidate: render cache instantly, refresh in background (00-shared/06 §3.3).
5. Optimistic updates
None. All mutations (create/update/deactivate) wait for the server envelope —
status changes and deletes have downstream audit events
(event-queue-map.ts:34-36) and no rollback path (00-shared/06 §3.5).
6. Cross-screen sync
StaffDetailCubitrefreshes after a successful edit;StaffListCubitrefreshes after create/delete (or removes the row locally onDELETEsuccess —(proposed)).- Catalog changes (department/designation) invalidate the 24 h catalog cache so pickers show fresh options (00-shared/06 §3.3).
- No WebSocket topics exist for staff (00-shared/07 §8 lists none) — no realtime updates; lists refresh on pull or refetch.
7. Error handling (module specifics)
| Code | Behavior |
|---|---|
| 400 | field errors onto fieldErrors; focus first invalid field |
| 401 | interceptor refresh once; fail → re-login (00-shared/06 §3.6) |
| 403 | hide actions (no staff.create → no FAB) + 403 screen if routed |
| 404 | detail → not-found empty-state; deactivate → snackbar "Already deactivated" |
| 409 | inline duplicate message on the field (00-shared/06 §5) |
| 429 | backoff copy; no auto-retry |
| 5xx | generic + requestId (00-shared/06 §5) |
8. Testing hooks
- Cubits are pure Dart with mocked repositories (00-shared/06 §6); each list cubit has widget-test pairs for skeleton/error/empty/success permutations; deactivate cubit tested for 404 and success paths.
- Repository mappers (envelope → model) unit-tested for all status/type enum values and missing refs.
14 — QA Checklist (Staff Module)
- 1. Uniqueness & duplicates
- 2. Department / designation delete with members
- 3. Deactivate rules
- 4. Status & enum fidelity
- 5. Pagination
- 6. Envelope & errors
- 7. Cross-module joins
- 8. Analytics
(proposed) - 9. Accessibility (shared baseline applied)
- 10. Performance & device matrix (shared budgets)
Module-specific QA items on top of 00-shared/10_QA_Baseline.md. Every item is derived from backend behavior;
(proposed)marks client-side choices.
1. Uniqueness & duplicates
-
Creating two staff with the same
employeeNumberin one tenant → second gets 409DUPLICATE_RESOURCEwith the exact message (staff.service.ts:31-36); inline error shown on the field; first record intact. -
Same
employeeNumberin different tenants succeeds (index is{tenantId, employeeNumber}—staff.schema.ts:57); cross-tenant isolation verified (E2E pattern:p1-school.e2e-spec.ts:445-459uses unique stamps). -
Same
userIdtwice in one tenant → 409 via unique index (staff.schema.ts:58) — a user can have only one staff profile per tenant. -
Duplicate department
name→ 409 (department.service.ts:23-25); duplicate designationname→ 409 (designation.service.ts:25-28). -
Re-using a deleted (soft-deleted)
employeeNumber→ still 409 (index includes soft-deleted rows —base.repository.ts:20-30filters reads, not the unique index) — document in deactivate confirm copy.
2. Department / designation delete with members
-
Deleting a department that has staff referencing it succeeds server-side
(no guard in
department.service.ts:62-65) — members'departmentIdnow dangles. Confirm dialog must warn (OQ-4). -
Same for designations (
designation.service.ts:65-68) and for department→designation links (designation.schema.ts:9-10). -
Detail screens render dangling refs as "—" (resolve via
GET /departments/:id→ 404 →(proposed)null render). -
Deleting a department that is another entity's
headId(staff as head) is not blocked — head ref dangles (department.schema.ts:15-16).
3. Deactivate rules
-
DELETE /staff/:idon an existing record → 200, row gone from list and detail 404 afterwards (base.repository.ts:68-74,staff.service.ts:93-104). -
Second
DELETEon same id → 404 (staff.service.ts:94-95); UI shows "already deactivated" without crash. -
Soft-deleted staff excluded from list and count (
base.repository.ts:20-30,53-55) →meta.totalItemsshrinks. -
Deactivate requires
staff.delete; without it the action is hidden (permissions.constants.ts:22; client gate — server RBAC pending OQ-1). -
No restore endpoint exists — QA must not spec an undo in UI
(
base.repository.ts:68-74; OQ-8).
4. Status & enum fidelity
-
Status badge shows exactly the 4 enum values (
staff.schema.ts:7-12); employment type exactly 4 (staff.schema.ts:14-19). -
Create never sends
status(not increate-staff.dto.ts); server respondsactive(staff.service.ts:39). -
Empty
employmentTypeon create → responsefull_time(staff.service.ts:40-41). -
statusfree-string risk: DTO validates only@IsString(update-staff.dto.ts:35-38) — a bogus status is persisted; client must restrict dropdown to enum values (OQ-9). -
levelnon-integer (e.g."2.5") → 400VALIDATION_ERRORwith details (designation.dto.ts:14-17;http-exception.filter.ts:103-107).
5. Pagination
-
page< 1 → 400;limit> 100 → 400 (pagination-query.dto.ts:6-19). -
Empty
totalItems→meta={page:1, limit:20, totalItems:0, totalPages:1, hasNext:false, hasPrevious:false}(pagination-query.dto.ts:46-54). -
Page controls/infinite scroll respect
meta.hasNext/hasPrevious; no duplicate requests for the same page (perf budget 00-shared/10 §1). -
qandsortsent but ignored — QA labels filters "filtering loaded results" (OQ-2); no crash on anysortvalue. -
Cross-check: staff list query params match
p1-school.e2e-spec.ts:461-470.
6. Envelope & errors
-
Every success payload:
success:true, message:"OK", data, meta?,timestamp,requestId(response-envelope.interceptor.ts:47-60). -
404 on staff/department/designation returns
RESOURCE_NOT_FOUND(http-exception.filter.ts:31), never exposes existence of cross-tenant ids. -
Invalid ObjectId in
:id→ 400 (http-exception.filter.ts:47-48,91-92). -
401 without Bearer token (
jwt-auth.guard.ts:36-38) → client refresh flow.
7. Cross-module joins
- S2 resolves department/designation names; a soft-deleted catalog entry renders "—" (404 on resolve).
-
S3 user picker requires a
userId(create-staff.dto.ts:5-7) — form cannot submit without one. -
Teachers module shares the catalogs (
teacher.schema.ts:22-26) — renaming a department here updates teacher displays on next fetch (no server denormalization).
8. Analytics (proposed)
-
staff.list.view/search/filter,staff.create.submit|success|duplicate,staff.update.submit|success,staff.deactivate.confirm|success,department.create.success|duplicate,designation.create.success|duplicatefire once per action (00-shared/10 §8 naming).
9. Accessibility (shared baseline applied)
- Status never color-only (icon + label) (00-shared/09 §9).
- TalkBack/VoiceOver walkthrough: create → deactivate journey without sight (00-shared/09 §12).
- Keyboard-only: chips, menus, dialogs operable; focus ring visible; ESC closes (00-shared/09 §6).
- Text scale 2×: employee-number rows and form grids reflow, no clipping (00-shared/09 §4).
- Dialog focus trap; focus returns to the deactivate trigger on cancel (00-shared/09 §6).
10. Performance & device matrix (shared budgets)
- Staff list first frame ≤ 300 ms cached / ≤ 2 s network (00-shared/10 §1).
-
0 dropped frames scrolling 500-row roster (profiled);
ListView.builderonly. - Verified on device matrix P0 (00-shared/10 §4): phone/tablet/desktop, light+dark, goldens at 3 sizes.
15 — Flutter Implementation Guide (Staff Module)
- 1. Folder structure
- 2. Models
- 3. Repositories
- 4. Routing (go_router)
- 5. Key widgets
- 6. State wiring
- 7. Testing
- 8. Performance
- 9. i18n
Extends 00-shared/11_Flutter_App_Architecture.md with the Staff module structure. Forward-looking spec (no client repo exists yet); all routes/guards mirror the backend surface.
1. Folder structure
lib/features/staff/
├── data/
│ ├── dto/staff_dto.dart # envelope-payload mappers
│ ├── dto/department_dto.dart
│ ├── dto/designation_dto.dart
│ ├── models/staff.dart # StaffStatus, EmploymentType enums
│ ├── models/department.dart
│ ├── models/designation.dart
│ └── repositories/
│ ├── staff_repository.dart # E1–E5
│ ├── department_repository.dart# E6–E10
│ └── designation_repository.dart # E11–E15
├── domain/
│ └── (none needed — 1:1 repo mapping; skip use cases, YAGNI)
└── presentation/
├── cubit/
│ ├── staff_list_cubit.dart
│ ├── staff_detail_cubit.dart
│ ├── staff_form_cubit.dart
│ ├── department_list_cubit.dart
│ ├── department_form_cubit.dart
│ ├── designation_list_cubit.dart
│ ├── designation_form_cubit.dart
│ └── deactivate_cubit.dart
├── pages/
│ ├── staff_list_page.dart
│ ├── staff_detail_page.dart
│ ├── staff_form_page.dart
│ ├── department_list_page.dart
│ ├── department_detail_page.dart
│ ├── department_form_page.dart
│ ├── designation_list_page.dart
│ ├── designation_detail_page.dart
│ └── designation_form_page.dart
└── widgets/
├── staff_status_badge.dart
├── employment_type_label.dart
├── employee_number_text.dart
├── ref_chip.dart
├── catalog_picker_sheet.dart
├── head_picker.dart
└── metadata_editor.dart
2. Models
enum StaffStatus { active, inactive, onLeave, terminated } // staff.schema.ts:7-12
enum EmploymentType { fullTime, partTime, contract, intern } // staff.schema.ts:14-19
class Staff {
final String id, tenantId, userId, employeeNumber;
final String? departmentId, designationId, salaryGrade;
final EmploymentType employmentType;
final DateTime? joiningDate;
final StaffStatus status;
final Map<String, dynamic> metadata;
final int version;
final DateTime createdAt, updatedAt;
}
- Parse unknown enum strings defensively (server accepts any string for
employmentType/status— no@IsEnum,create-staff.dto.ts:23-28,update-staff.dto.ts:35-38): fall back to aStaffStatus.unknown/raw display instead of throwing (OQ-9). DateTime.tryParseforjoiningDate(staff.service.ts:42storesDate).- JSON mapping via
json_serializable(codegen preferred per00-shared/11 §4).
3. Repositories
StaffRepository:list({page, limit})→Paginated<Staff>(GET /staff, E2) — do not sendsort/qsemantics (server ignores them;staff.service.ts:64-76).get(id)→Staff(E3);create(dto)(E1);update(id, Map<String,dynamic> delta)(E4 — send only changed keys,staff.service.ts:88);deactivate(id)(E5).
DepartmentRepository:list,get,create,update(E6–E9),deactivate(E10).DesignationRepository:list,get,create,update(E11–E14),deactivate(E15).- All mutations non-optimistic; throw typed
ApiException(code, status, details)(00-shared/06 §2,3.5). - Cross-module joins live in client-side composition (UsersRepository for names, catalogs for refs) — never block list rendering on join fetches; render placeholders and fill in.
4. Routing (go_router)
GoRoute(
path: '/staff',
name: 'staff.list',
pageBuilder: ... StaffListPage.new,
routes: [
GoRoute(path: 'new', name: 'staff.new', builder: (_) => StaffFormPage(mode: create)),
GoRoute(path: ':id', name: 'staff.detail', builder: ... StaffDetailPage.new,
routes: [
GoRoute(path: 'edit', name: 'staff.edit', builder: (_) => StaffFormPage(mode: edit)),
]),
],
),
GoRoute(path: '/departments', ...), // list / new / :id / :id/edit
GoRoute(path: '/designations', ...), // list / new / :id / :id/edit
- Guards:
permissionGuard('staff.read' | 'staff.create' | 'staff.update' | 'staff.delete' | 'department.manage' | 'designation.manage')— client mirror ofpermissions.constants.ts:19-24; server RBAC pending (OQ-1) so guards are mandatory, not decorative. - Routes registered inside the Staff shell branch; master-detail via
StatefulShellRouteat ≥ 840 dp (00-shared/05 §3). - Deep links:
studylyon://staff/:id,/departments/:id,/designations/:id((proposed)).
5. Key widgets
| Widget | Impl notes |
|---|---|
StaffStatusBadge | AppBadge wrapper; 4-value map (staff.schema.ts:7-12); Semantics('Status: …') |
EmployeeNumberText | SelectableText, mono font, tabularFigures (00-shared/02 §2) |
RefChip | resolves ref via repository; null/404 → "—" |
CatalogPickerSheet | showModalBottomSheet + SearchBar + ListView.builder; paginated catalogs; create affordance in empty state |
MetadataEditor | key/value rows → Map<String, dynamic> |
DeactivateDialog | showDialog + AlertDialog; destructive action; Navigator.pop(result) into DeactivateCubit.confirm |
6. State wiring
StaffListPage→BlocProvider(StaffListCubit):Loadon init;LoadMoreon scroll end;RefreshIndicator.onRefresh→Refresh; chip callbacks →ChangeStatusFilter/ChangeTypeFilter(client-side over loaded pages, OQ-2).StaffDetailPage→StaffDetailCubit(parallel fetches: staff + dept + desig + user viaFuture.wait; each failure degrades independently).StaffFormPage(mode)→StaffFormCubit: create submitsCreateStaffDto-shaped map; edit submits only changed keys;ApiException.code == 'DUPLICATE_RESOURCE'→ field error onemployeeNumber.- Deactivate: dialog-owned
DeactivateCubit; on success pop dialog + refresh list.
7. Testing
- Unit: model enum parsing (including bogus server strings); repository
envelope mapping (paginated + error); cubits with mocked repos — pagination
edge (
hasNextfalse), 409 field mapping, 404 on detail. - Widget: 3-state tests per page (
00-shared/10 §9); golden tests forStaffStatusBadge(4 states), pickers, forms. - Integration: journey test "create staff → 409 on duplicate → success →
detail → deactivate → gone from list" using
integration_test+ mocked or test-tenant API (p1-school.e2e-spec.ts:437-478mirrors the same journey). - A11y: TalkBack/VoiceOver walkthrough for create + deactivate
(
00-shared/09 §12).
8. Performance
ListView.buildereverywhere;RepaintBoundaryper avatar row in lists;constconstructors; catalog pickers cache 24 h (00-shared/06 §3.3).- Defer user-name joins to a background fill after first frame
(
00-shared/11 §13).
9. i18n
Keys under staff.*: staff.list.title, staff.form.employeeNumber (+ helper
"Created as active", staff.service.ts:39), staff.status.active|inactive| onLeave|terminated, staff.deactivate.title|body|confirm,
department.form.name, designation.form.level, etc. Server 409 message shown
via business-4xx fallback (00-shared/11 §9).
01 — Product Overview (Students Module)
- 1. Purpose
- 2. The student lifecycle (backend state machine)
- 3. Business goals
- 4. User goals
- 5. Stakeholders
- 6. Dependencies
- 7. Success metrics
- 8. Edge cases (server truth)
- 9. Module notes
- 10. Forward-looking & PRD notes
- 11. Assumptions (module)
- 12. Open questions (module-level; global ledger in
00-shared/12) - 13. Glossary (this module)
StudyLyon — multi-tenant ERP / School Management API. This package designs the Students module client (Flutter, forward-looking spec) against the implemented NestJS backend. Every endpoint, DTO field, schema, enum, event, and wire contract is derived from
src/modules/students/**,src/modules/parents/**,src/modules/academics/**,src/modules/bulk/**,src/modules/users/**,src/modules/rbac/permissions.constants.ts,src/infrastructure/**, andstudylyon-blueprint/04-Modules/Students.md. Nothing is invented; gaps are flagged(planned),(forward-looking),(proposed)or listed in Assumptions & Open Questions.
1. Purpose
Students is the academic heart of a school: it holds every enrolled child's academic profile, their enrollment history per class and academic year, transfers and promotions, graduation/withdrawal, archived records, uploaded documents, and the links to parents/guardians that power family portals. It is the anchor record that attendance, fees, results, library, transport and homework modules hang off.
| Responsibility | Source |
|---|---|
| Profile creation with automatic first enrollment | student.service.ts:55-93 |
| Enrollment into a class for an academic year (history kept, never overwritten) | student.service.ts:120-140, class-enrollment.schema.ts:7-44 |
Transfer between classes (guard: only active) | student.service.ts:170-203 |
| Graduate / archive / restore lifecycle | student.service.ts:205-248 |
| Document upload + listing per student | student.service.ts:250-279, student-document.schema.ts |
| Academic history (all enrollments, newest first) | student.service.ts:281-287 |
| Bulk CSV import / export with per-row report | bulk/bulk-import.service.ts:22-71, bulk/adapters/students-import.adapter.ts |
| Parent/guardian linking (many-to-many, relationship typed) | parents/services/student-parent-link.service.ts, parents/schemas/student-parent-link.schema.ts |
| Domain events → BullMQ (in-app + audit) | infrastructure/bullmq/event-queue-map.ts:28-39 |
| Soft-delete + optimistic-version scoping (tenant-safe) | database/base.repository.ts:20-74 |
2. The student lifecycle (backend state machine)
Status values are fixed by StudentStatus (student.schema.ts:7-13):
active | inactive | graduated | transferred | archived.
┌────────────────────────────────────────────┐
│ POST /students (create) │
│ status := active (always) │
│ auto-enroll: ACTIVE enrollment created │
└────────────────────────────────────────────┘
│
┌────────────────────────┼───────────────────────────┐
▼ ▼ ▼
[active] ── transfer ──► enrollment TRANSFERRED + new ACTIVE ──► stays [active]
[active] ── graduate ──► status = graduated (409 if already graduated)
[active] ── archive ──► status = archived (409 if already archived)
[archived] ── restore ─► status = active (409 if already active)
any ── DELETE /students/:id ──► soft-delete (isDeleted=true, excluded from all queries)
- Create forces
status: ACTIVEandadmissionDate = todaywhen omitted (student.service.ts:65-69), then immediately creates an ACTIVEclass_enrollmentsrow for the givenclassId/academicYearId(student.service.ts:71-78). Admission number is unique per tenant — duplicate → 409 (student.service.ts:56-62,student.schema.ts:68). - Enroll deactivates all currently-active enrollments
(
status: transferred,leftAt: now) and creates a fresh ACTIVE one (student.service.ts:125-140). History is append-only — previous years are never overwritten (blueprint03-Database/COLLECTIONS.md:1674-1677). - Transfer is
enroll+ profile sync: requires current statusactive(else 409), then updatesclassId,academicYearId, optionalgradeId/sectionIdon the student doc (student.service.ts:170-203). - Graduate sets status
graduated; idempotency guard → 409 when already graduated (student.service.ts:205-223). Note: graduation does not close the active enrollment — flagged in OQ-5. - Archive / restore toggles
archived↔activewith a same-state 409 guard (student.service.ts:225-248). inactive/transferredstatuses exist in the enum but no service method writes them — only legacy data or future use (OQ-6).- DELETE soft-deletes the student record only — documents, enrollments and
parent links are not cascaded (OQ-7,
student.service.ts:157-168).
3. Business goals
| Goal | Measure |
|---|---|
| Zero duplicate admission numbers per tenant | unique compound index {tenantId, admissionNumber} (student.schema.ts:68) + pre-check 409 |
| Enrollment history is a never-overwritten audit trail | deactivate-then-create pattern (student.service.ts:125-140) |
| Every status change is observable | StudentUpdated → audit-write queue (event-queue-map.ts:29) |
| Safe transfers | only active students transfer; 409 otherwise (student.service.ts:175-179) |
| Tenant isolation structural | BaseRepository.scopedFilter injects tenantId + isDeleted:false on every query (base.repository.ts:20-30) |
| Admissions in bulk | CSV import with per-row error report (1000-row class in 14_QA_Checklist.md) |
4. User goals
- Admission staff: create a student (identity via an existing user), set the class, upload documents, link parents — all from the student profile.
- Admin: keep the roster accurate (transfer, graduate, archive), import the September batch from a spreadsheet, spot duplicates.
- Teacher: find a student quickly (search/filter), view profile, documents, academic history.
- Parent: see linked children via
GET /parents/:id/students(parent.controller.ts:38-40) — read-only; profile changes are staff-side. - Student (self): read own profile, academic history, documents
(planned)— the backend has nostudent-selfendpoints today (OQ-1).
5. Stakeholders
School admins, admission/reception staff, teachers, parents/guardians, students,
accounts (enrollment status feeds fee invoicing), transport/hostel coordinators
(transportRequired/hostelRequired flags), platform operator (tenant data).
6. Dependencies
| Dependency | Role | Source |
|---|---|---|
| Users module | identity (name/email/phone/avatar) — students stores academic data only | user.schema.ts:15-79; blueprint COLLECTIONS.md:1283-1291 |
| Academics module | academicYearId, gradeId, sectionId, classId refs + dropdown source endpoints | academics/controllers/*.ts, schemas in academics/schemas/ |
| Parents module | guardian links (student_parent_links M2M) | student-parent-link.schema.ts |
| Bulk module | CSV import/export (students adapter) | bulk/bulk-import.service.ts, students-import.adapter.ts |
| Storage provider | document + avatar binaries | shared/storage/storage-provider.ts, storage/local-storage.provider.ts:23-51 |
| EventBus → BullMQ | StudentCreated/StudentUpdated/StudentDeleted → in-app + audit jobs | event-queue-map.ts:28-30 |
| RBAC constants | student.read/.create/.update/.delete (note: not yet enforced — OQ-4) | permissions.constants.ts:25-28 |
| Collections | students, class_enrollments, student_documents, student_parent_links, parents | blueprint 03-Database/COLLECTIONS.md |
7. Success metrics
- Enrollment round-trip (create + auto-enroll) acknowledged in < 2 s p95.
- Zero duplicates: 409 on admission number must be prevented client-side by inline pre-check + server 409 fallback.
- Transfer flow error rate < 1% (guard 409s are user-preventable).
- CSV import of 1000 rows completes with a per-row report; 0 silent skips.
- Document uploads land with correct
fileIdmapping; previews resolvable. - Parent links never orphaned (student existence checked at link time —
student-parent-link.service.ts:27).
8. Edge cases (server truth)
- Duplicate
admissionNumber→ 409ConflictException(student.service.ts:56-62). findById/enroll/transfer/update/remove/graduate/archive/restore/uploadDocumenton unknown id → 404 "Student not found." (student.service.ts:95-99,124,143-145,159,174,206,226,255).- Transfer/graduate/archive on wrong status → 409 with the current status in the
message (
student.service.ts:175-179,207-209,228-230). - Enroll while another enrollment is active → previous becomes
transferred(no error; expected workflow) (student.service.ts:125-131). - Pagination:
sortandqquery params are accepted but ignored byStudentService.find(student.service.ts:104-108) — list search/sort is(planned)server-side (OQ-8). - CSV malformed/empty → 400; unknown entity → 404; per-row failures reported,
never abort the batch (
bulk-import.service.ts:31-63). - Duplicate email inside one CSV →
UsersService.createfails → adapter falls back to the existing user (re-link) (students-import.adapter.ts:66-75).
9. Module notes
POST /studentsrequires an existinguserId(create-student.dto.ts:6-7) — the UI must create the user (or reuse an existing one) before creating the student. There is no "create user + student" composite endpoint.- Document upload has no server-side size/mime validation — only the
multipart field must exist; limits are client + proxy level today (OQ-9,
student.service.ts:250-271). StudentCreatedpayload carriesstudentId,admissionNumber,classId(student.service.ts:85-90); it routes to thein-appqueue only — no email (email worker handles onlyUserRegistered/PasswordResetRequested,email.worker.ts:26-42). PLAN 4.1's "StudentCreated → ParentCreated → email" chain is therefore(planned)(OQ-3).- RBAC:
student.*permissions exist as constants (permissions.constants.ts:25-28) but the students controller appliesJwtAuthGuardonly (student.controller.ts:32-35);RbacGuardis not wired on these routes — permission enforcement is(planned)(OQ-4). The client should still gate UI bystudent.*per00-shared/05 §9. - No
parent.*permissions exist inpermissions.constants.tsat all — the parents surface has no permission vocabulary yet (OQ-10).
10. Forward-looking & PRD notes
- PRD: native mobile apps are Phase 3 (read-only companion) — this package is
the forward-looking full client spec (shared ledger
00-shared/12 A1). (planned)(server):POST /students/bulk-importunder/students(docs/IMPLEMENTATION_PLAN.md:195— today import lives at/bulk/import/students);promoteendpoint +StudentPromotedevent (blueprint04-Modules/Students.md:32,43); student self-service (results/attendance read for the linkeduserId).(forward-looking): profile photo upload atPOST /users/:id/avatar(users.controller.ts:95-102) exists server-side; QR admission cards, push ofStudentCreatedto parents, WS live roster updates.(proposed): analytics events (students.list.search,students.import.done…) per00-shared/10 §8.
11. Assumptions (module)
- The client treats
activeas the default roster filter;archivedstudents are hidden from lists unless "include archived" is toggled (serverfind()returns everything not soft-deleted — filtering is client-side today, OQ-8). - Class dropdown data comes from
GET /classes(+by-year/:academicYearId), grades fromGET /grades, sections fromGET /sections/by-grade/:gradeId, academic years fromGET /academic-years(academics/controllers/*.ts). - Identity edits (name/email/phone/avatar) are Users module screens; the Students UI shows them read-only from the linked user.
- Multipart uploads use field name
fileexactly (student.controller.ts:83,bulk.controller.ts:38,users.controller.ts:96).
12. Open questions (module-level; global ledger in 00-shared/12)
| # | Item | Impact |
|---|---|---|
| OQ-1 | No self-service endpoints for students/parents (no "my profile", no GET /students/me). Parent view exists only via GET /parents/:id/students. When is student self-view added? | Student/Parent persona screens (03, 06) |
| OQ-2 | StudentService.find accepts sort/q but ignores them — server-side search/sort/status filter planned? | List screen filter behaviour (05, 12, 13) |
| OQ-3 | PLAN 4.1 chain "StudentCreated → ParentCreated → email" — no auto-parent-creation or student/parent email in code (email.worker.ts:26-42). Intended? | Journey "link parent" UX |
| OQ-4 | student.* perms defined but RbacGuard not applied on students/parents/bulk controllers — enforcement when? | Permission gating in UI (04, 05) |
| OQ-5 | graduate sets status but leaves the ACTIVE enrollment open — close it (leftAt) as part of graduation? | Academic-history rendering |
| OQ-6 | inactive/transferred student statuses unwritable by any service method — legacy or future workflow? | Status chip legend |
| OQ-7 | DELETE /students/:id soft-deletes the student only — documents/links/enrollments stay. Cascade or keep history? | Deletion UX copy |
| OQ-8 | List has no status filter param — archiving hides nothing from GET /students. Client-side filtering or new query param? | Roster filtering |
| OQ-9 | Document upload: no server size/type limits; student_documents has no downloadable-file endpoint (only fileId) — file retrieval via /api/v1/files/... local path (planned) | Documents tab + previews |
| OQ-10 | No parent.* permission constants — how are parent CRUD routes authorized beyond JWT? | Parent link UI gating |
13. Glossary (this module)
| Term | Meaning |
|---|---|
| Student | students doc — academic profile only; identity in linked User |
| Admission number | tenant-unique identifier (ADM…), student.schema.ts:20-21,68 |
| Enrollment | class_enrollments row: student ↔ class ↔ academic year, with joinedAt/leftAt/status |
| Class | classes doc = grade + section + academic year (+capacity) — class.schema.ts |
| Transfer | enroll-into-new-class with old enrollments set transferred |
| Graduate/Archive | status transitions active → graduated/archived (mutually exclusive guards) |
| Parent link | student_parent_links M2M row with relationship, guardian/priority flags |
| Import report | {entity,totalRows,imported,failed,errors:[{rowNumber,errors}]} (import-adapter.interface.ts:14-25) |
| Envelope | {success,message,data,meta?,timestamp,requestId} (00-shared/07 §2-3) |
02 — User Personas (Students Module)
- 1. Organization Admin — "Ravi, ERP owner"
- 2. Admission Staff / Receptionist — "Priya, front office"
- 3. Teacher / Class Teacher — "Ananya, Grade 5-A teacher"
- 4. Parent / Guardian — "Farid, father of two"
- 5. Student Self — "Meera, Grade 6"
- Persona → capability matrix (derived from permissions constants only)
Five personas exercise the Students module. Role → permission vocabulary derives from
permissions.constants.ts:25-28(student.read/.create/.update/.delete); note enforcement is(planned)(see 01 §12 OQ-4). Persona facts marked(forward-looking)reflect module docs (attendance/fees/results tabs) that read other modules' APIs.
1. Organization Admin — "Ravi, ERP owner"
| Profile | 40s, principal/owner of a 1200-student school. Runs the ERP end to end; reviews numbers every evening. |
| Goals | Roster integrity (no dupes, correct classes), clean academic-year rollover, sees statuses at a glance, imports the new batch in September without IT help. |
| Pain points | Duplicate admission numbers (409s), stale archived students polluting the list, transfer mistakes (enrolling into wrong year). |
| Behaviours | Uses desktop/web; bulk import wizard; reviews per-row CSV error report; audits academic history before approving transfers. |
| Permissions | student.read, student.create, student.update, student.delete + user.*, academics read (permissions.constants.ts:25-28,6-11). |
| Key screens | Students list, bulk import wizard, student detail → academic-history tab, transfer form. |
2. Admission Staff / Receptionist — "Priya, front office"
| Profile | 25s, first point of contact; admits 10–30 students/week during admission season. |
| Goals | Create a student profile in under 2 minutes with the parent present; upload the birth certificate immediately; link the parent so fee/results messages reach home. |
| Pain points | Forgetting that the user record must exist before POST /students; typing admission numbers; re-finding a student's documents. |
| Behaviours | Phone-first; uses create wizard with camera capture for documents (capture → POST /students/:id/documents); checks duplicate admission number inline before submit. |
| Permissions | student.create, student.read (+ user.create for the identity step). |
| Key screens | Create wizard (steps: identity → academics → extras → confirm), document upload, parent link sheet. |
3. Teacher / Class Teacher — "Ananya, Grade 5-A teacher"
| Profile | 32s, class teacher of 5-A; also marks attendance and homework. |
| Goals | Find any child by name/admission number quickly; see profile photo + parent pickup flags; view academic history before a parent meeting. |
| Pain points | Unfilterable rosters (GET /students returns everything — client-side filtering today, OQ-2), statuses not obvious in the list. |
| Behaviours | Uses search + filter chips; opens student detail; rarely mutates (no create/transfer). |
| Permissions | student.read (+ attendance.mark etc. per other modules). |
| Key screens | Students list (search), student detail → profile + documents tabs. |
4. Parent / Guardian — "Farid, father of two"
| Profile | 40s, two children at the school; busy; uses the app on mobile. |
| Goals | See both children in one place, their class, documents (fee receipts live in Fees module), and be marked as primary guardian so the school calls him first. |
| Pain points | None today server-side — parent has no self-service read of the student profile (OQ-1); current access is only GET /parents/:id/students which returns link rows, not student details (parent.service.ts:69-72). |
| Behaviours | Reads only; receives in-app notifications (ParentCreated → in-app, event-queue-map.ts:37); contacts office for any change. |
| Permissions | None defined for parents (OQ-10); UI shows read-only surfaces. |
| Key screens | (forward-looking) Children list → child overview. |
5. Student Self — "Meera, Grade 6"
| Profile | 11s; uses the app for homework/results (other modules). |
| Goals | See her own class, academic history (promotions), and documents; keep her avatar current. |
| Behaviours | Read-only; avatar upload via POST /users/:id/avatar (users.controller.ts:95-102) (forward-looking). |
| Permissions | None in code for self-service students (OQ-1). |
| Key screens | (forward-looking) My Profile → academic history, documents. |
Persona → capability matrix (derived from permissions constants only)
| Capability | Admin | Admission | Teacher | Parent | Student |
|---|---|---|---|---|---|
List/search students (student.read) | ✓ | ✓ | ✓ | — | — |
Create student (student.create) | ✓ | ✓ | — | — | — |
Update profile (student.update) | ✓ | — | — | — | — |
Delete (soft) (student.delete) | ✓ | — | — | — | — |
| Transfer / graduate / archive | via student.update semantics | — | — | — | — |
| Upload documents | via file.upload + student.update (permissions.constants.ts:87) | ✓ | — | — | — |
| Bulk import | (planned) — no student.import constant exists (blueprint lists it, code doesn't; permissions.constants.ts has only the four student.*) | — | — | — | — |
| Link parents | via parent routes (no parent.* perms — OQ-10) | ✓ | — | — | — |
| Self view | — | — | — | (planned) OQ-1 | (planned) OQ-1 |
Notes:
student.promote/student.importare in the blueprint (04-Modules/Students.md:70-76) but absent frompermissions.constants.ts— flag if import UI needs a dedicated gate.- The bulk controller (
bulk.controller.ts:28-31) is JWT-only today — import is technically open to any authenticated user untilRbacGuardlands (OQ-4).
03 — User Journeys (Students Module)
- 1. Create + enroll student (admission desk)
- 2. Link parent / guardian
- 3. Transfer student to another class
- 4. Upload student documents
- 5. Bulk CSV import (admissions batch)
- 6. Graduate / withdraw student
- 7. Student self-view
(forward-looking) - Journey → endpoint inventory (summary)
Happy-path + failure branch journeys with Mermaid. Every endpoint referenced is exact (
src/modules/students/controllers/student.controller.ts,src/modules/parents/ controllers/parent.controller.ts,src/modules/bulk/bulk.controller.ts). All journeys assume an authenticated JWT;tenantIdcomes from the token only.
1. Create + enroll student (admission desk)
sequenceDiagram
actor R as Admission staff
participant UI as Students UI
participant API as POST /users + POST /students
participant Q as BullMQ (in-app / audit)
R->>UI: Open "Add student" wizard
UI->>API: POST /users {firstName,lastName,email} (identity step)
alt user already exists (409/duplicate email)
API-->>UI: 409 — staff searches existing user instead
UI->>API: GET /users?q=email (reuse existing userId)
else created
API-->>UI: 200 user {_id}
end
UI->>API: POST /students {userId, admissionNumber, classId, academicYearId, gradeId, sectionId, admissionType…}
Note over API: duplicate admissionNumber → 409 ConflictException<br/>(student.service.ts:56-62); else status=active + auto-enroll ACTIVE (71-78)
alt duplicate admission number
API-->>UI: 409 DUPLICATE_RESOURCE — inline field error, staff corrects
else success
API-->>UI: 200 student doc (with enrollment created)
UI-->>R: Success snackbar → detail screen
end
Note over Q: StudentCreated → in-app "student-enrolled"<br/>(event-queue-map.ts:28); audit log via StudentCreated/audit
Failure branches: 400 validation (missing userId, malformed admissionNumber),
409 duplicate, 5xx. Identity-first ordering is a hard server constraint: CreateStudentDto.userId
is required (create-student.dto.ts:6-7).
2. Link parent / guardian
sequenceDiagram
actor R as Admission staff
participant UI as Student detail → Parents tab
participant API as POST /parents + POST /parents/link/:studentId
R->>UI: "Add parent" (choose existing or create new)
alt new parent
UI->>API: POST /users (guardian identity)
UI->>API: POST /parents {userId, occupation?, pickupAuthorization?}
Note over API: duplicate userId → 409 (parent.service.ts:30-34)
end
UI->>API: POST /parents/link/:studentId {parentId, relationship, isPrimaryGuardian?, financialResponsibility?, pickupAllowed?, emergencyPriority?}
Note over API: student existence checked (student-parent-link.service.ts:27);<br/>parent existence NOT checked (OQ-11)
alt success
API-->>UI: 200 link doc (relationship enum: mother|father|guardian|grandparent|relative|foster_parent — student-parent-link.schema.ts:7-14)
UI-->>R: Snackbar "Guardian linked"; Parents tab refreshes
else 404 student
API-->>UI: 404 RESOURCE_NOT_FOUND
end
opt unlink
UI->>API: DELETE /parents/link/:linkId (soft delete; 404 if missing — student-parent-link.service.ts:38-41)
end
Relationship is required on the link DTO (link-parent.dto.ts:9-20); all four
flags are optional booleans with schema defaults (student-parent-link.schema.ts:27-37).
3. Transfer student to another class
flowchart TD
A[Student detail → Actions → Transfer] --> B{status == active?}
B -- no --> C[Blocked: 409 'Cannot transfer a student with status X'<br/>student.service.ts:175-179 — show status banner]
B -- yes --> D[Transfer form: class + academic year<br/>+ optional grade/section/rollNumber<br/>transfer-student.dto.ts]
D --> E[POST /students/:id/transfer]
E --> F{Server}
F -- ok --> G[Old enrollments set TRANSFERRED + leftAt<br/>student.service.ts:125-131 via enroll()]
F -- ok --> H[Student classId/academicYearId updated + optional gradeId/sectionId<br/>student.service.ts:185-192]
F -- ok --> I[StudentUpdated → audit 'log-student-updated'<br/>event-queue-map.ts:29]
G & H & I --> J[UI: refresh detail + academic history<br/>snackbar 'Transferred']
F -- 404 --> K[Student not found]
F -- 5xx --> L[Generic + requestId]
gradeId/sectionId are optional and default to the target class's own grade/section
(transfer-student.dto.ts:13-25).
4. Upload student documents
sequenceDiagram
actor R as Admission staff
participant UI as Documents tab
participant API as POST /students/:id/documents
participant S as StorageProvider
R->>UI: Tap "+ Document" → pick/capture file
UI->>API: multipart POST file=… + field category (optional, string)
Note over API: FileInterceptor('file') (student.controller.ts:82-90);<br/>no size/type validation server-side (OQ-9)
API->>S: storage.upload({buffer, filename, mimeType, tenantId}) → fileId
S-->>API: fileId (local: "<tenantId>/<uuid>--<name>" — local-storage.provider.ts:26-30)
API-->>UI: 200 document doc {fileName, mimeType, size, fileId, category, uploadedBy}
UI-->>R: Attachment tile appears; list sorted createdAt desc (student.service.ts:273-279)
Note over UI: Preview/download needs a file-serving endpoint — only fileId returned today (OQ-9)
5. Bulk CSV import (admissions batch)
flowchart TD
A[Students list → menu → Import] --> B[Download template GET /bulk/export/students<br/>bulk.controller.ts:50-60 — exports admissionNumber, rollNumber, status, admissionDate]
B --> C[Fill rows: firstName*, lastName*, email*, admissionNumber*,<br/>grade*, section*, academicYear*, rollNumber?<br/>students-import.adapter.ts:17-26]
C --> D[Pick CSV → POST /bulk/import/students<br/>multipart field 'file']
D --> E{Parse}
E -- malformed/empty --> F[400 'Malformed CSV' / 'CSV must include a header row'<br/>bulk-import.service.ts:31-35]
E -- ok --> G[Per-row validate + create<br/>rowNumber = index + 2 (header = row 1)<br/>bulk-import.service.ts:45-63]
G --> H[Report: totalRows, imported, failed, errors[{rowNumber, errors}]]
H --> I[UI: result screen — imported count, failed rows list with<br/>per-row messages: missing columns, invalid email,<br/>duplicate admission, duplicate email,<br/>academicYear/grade/section not found, no class for grade+section<br/>students-import.adapter.ts:38-60,100-144]
I --> J[Retry failed rows after fixing CSV]
Import is synchronous in code (loop in the request) — no BullMQ progress job today; the "import progress" state is client-side simulation of a long request (OQ-12). A 1000-row file = 1000 sequential create calls.
6. Graduate / withdraw student
flowchart TD
A[Student detail → Actions → Graduate] --> B[Confirm dialog]
B --> C[POST /students/:id/graduate]
C --> D{status == graduated?}
D -- yes --> E[409 'Student is already graduated.' student.service.ts:207-209]
D -- no --> F[status := graduated; StudentUpdated ['graduate'] → audit]
A2[Student detail → Actions → Archive] --> B2[Confirm dialog<br/>copy: hidden from roster, restorable]
B2 --> C2[POST /students/:id/archive]
C2 --> D2{status == archived?}
D2 -- yes --> E2[409 student.service.ts:228-230]
D2 -- no --> F2[status := archived; StudentUpdated ['archive']]
F2 --> G[Restore: POST /students/:id/restore → active<br/>student.controller.ts:74-77]
Note over G: There is NO 'withdraw' status — withdrawal = archive today<br/>(enum has inactive/transferred but unwritable, OQ-5/OQ-6)
7. Student self-view (forward-looking)
sequenceDiagram
actor S as Student (linked userId)
participant UI as My Profile
participant API as (planned) student-self endpoints — OQ-1
S->>UI: Open My Profile
Note over UI: No GET /students/me exists. Today the student's userId<br/>has no student-scoped endpoint; profile reachable only via staff routes.
UI->>API: (planned) GET /students/me → profile + academic history + documents
Note over UI: Alternative interim: staff shares a read-only link?<br/>Blocked on OQ-1 decision.
Journey → endpoint inventory (summary)
| Journey | Primary endpoints | Source |
|---|---|---|
| Create + enroll | POST /users, POST /students | users.controller.ts:36-40, student.controller.ts:38-40 |
| Link parent | POST /parents, POST /parents/link/:studentId, DELETE /parents/link/:linkId | parent.controller.ts:29-31,47-60 |
| Transfer | POST /students/:id/transfer | student.controller.ts:62-65 |
| Documents | POST /students/:id/documents, GET /students/:id/documents | student.controller.ts:78-90 |
| Bulk import | POST /bulk/import/students, GET /bulk/export/students | bulk.controller.ts:35-60 |
| Graduate/archive | POST /students/:id/graduate|archive|restore | student.controller.ts:66-77 |
| Academic history | GET /students/:id/academic-history, GET /students/:id/enrollments | student.controller.ts:47-49,91-94 |
04 — Information Architecture (Students Module)
- 1. Placement in the app shell
- 2. Route tree
- 3. Screen relationships (master-detail)
- 4. Navigation & actions per screen
- 5. Filter & search model (list screen)
- 6. Status vocabulary (UI badge set)
- 7. Cross-cutting ownership
Where Students lives in the global shell (00-shared/05) and how its screens nest. Routes are module-level; base shell routes in 00-shared/05 §4. Permission-gating per 00-shared/05 §9 (client mirrors
permissions.constants.ts; server enforcement(planned)— 01 §12 OQ-4).
1. Placement in the app shell
AppShell
├─ NavigationBar / Rail / Drawer
│ └─ "Students" destination (route prefix /students)
│ roles (default): admin, staff, teacher (read) — 00-shared/05 §2
│
└─ /students branch (StatefulShellBranch — tab state survives nav switches)
- Hidden for parent/student roles today (they have no
student.read; self-view is(planned)OQ-1). If a parent/student opens a shared student link, the guard rejects → 403 screen (00-shared/05 §8).
2. Route tree
/students Students list (roster)
├── /students/add Create wizard (user identity → academic → extras → confirm)
├── /students/import Bulk CSV import wizard (+ result report)
├── /students/:id Student detail (tabs)
│ ├── profile Tab: profile summary + identity + status actions
│ ├── attendance Tab: (other module — forward-looking read)
│ ├── fees Tab: (other module — forward-looking read)
│ ├── results Tab: (other module — forward-looking read)
│ ├── documents Tab: document list + upload
│ └── history Tab: academic history + enrollments (server-backed)
├── /students/:id/edit Profile edit form (PATCH)
├── /students/:id/transfer Transfer form (POST transfer)
└── (sheets, not routes)
├── Enroll form (POST /students/:id/enroll)
├── Link parent (POST /parents + /parents/link/:studentId)
├── Document upload (multipart POST documents)
├── Graduate / Archive / Restore / Delete confirmations
└── Status filter sheet (client-side filter — OQ-8)
Deep links (per 00-shared/05 §4 convention):
studylyon://students/:id → detail (documents tab); studylyon://students/:id/documents/:docId (planned) — requires file-serving endpoint (OQ-9).
3. Screen relationships (master-detail)
List ──tap──► Detail ──actions──► Forms (edit/transfer/enroll/graduate/archive)
│ │
│ └──► Documents (tab) ──upload──► confirm → tab refresh
│ └──► Parents (tab) ──link sheet──► refresh
│ └──► History (tab) — server-sorted joinedAt desc
└──menu──► Import wizard ──result report──► back to list (refresh)
- Phone: push-on-top; tablet/desktop ≥ 840 dp: master-detail two-pane
(
00-shared/05 §6,00-shared/04 §6). - Edit/transfer return to detail with refresh (never a stale cache).
- List refresh after: create (new student first page), import (count delta), restore (item reappears), delete (item leaves).
4. Navigation & actions per screen
| Screen | AppBar | FAB / primary CTA | Row actions (AppMenu) | Swipe (phone) |
|---|---|---|---|---|
| List | Title "Students", global search icon | + Add student (student.create) | View / Edit / Transfer / Archive / Restore / Delete | — (roster rows have menu instead) |
| Import | Title, close | "Import CSV" (submit) | — | — |
| Detail | Student name, back, overflow (Edit, Transfer, Graduate, Archive, Delete) | Contextual per tab | Tab-dependent | — |
| Documents tab | — | + Upload (file.upload+student.update) | Preview (planned) / Remove (planned) | delete (planned) |
Rule: destructive/irreversible actions need AppDialog confirm (00-shared/05 §5):
Delete (typed confirm not needed — soft delete), Graduate, Archive. Transfer is
reversible via re-transfer but confirm anyway (state change).
5. Filter & search model (list screen)
- Search box (
AppSearchBar): client filters loaded page(s) by name/ admission number/roll number today (qignored server-side — OQ-2). - Filter chips (client-side until server filters land):
- Status: all / active / graduated / archived (+ inactive/transferred when data exists — OQ-6)
- Class: pick class → filter
- Academic year
- Sort (client-side on loaded page): name, admission number, admission date.
- Empty states: no students ("Add your first student"), no results for filters ("No students match — clear filters").
6. Status vocabulary (UI badge set)
| Status (enum) | Badge | Meaning | Source |
|---|---|---|---|
active | ✓ success | Current enrollment ACTIVE | student.schema.ts:8 |
inactive | neutral | Enum exists; no writer today (OQ-6) | student.schema.ts:9 |
graduated | tertiary | Completed; guarded 409 | student.schema.ts:10 |
transferred | neutral | Enum exists; enrollment rows use it, student status doesn't | student.schema.ts:11 |
archived | error-ish (muted) | Withdrawn/left; restorable | student.schema.ts:12 |
Enrollment statuses (class-enrollment.schema.ts:7-11): active, inactive,
transferred — shown inside history tab timeline, not as student badge.
7. Cross-cutting ownership
| Concern | Owner | Note |
|---|---|---|
| Identity fields (name, email, phone, avatar) | Users module (/users/:id) | Students UI reads via linked userId; avatar edit → POST /users/:id/avatar (users.controller.ts:95-102) |
| Attendance / fees / results tabs | Those modules | Forward-looking; read APIs from those modules |
| Global search | /search (00-shared/05 §3) | GET /api/v1/search?q= (planned) (docs/IMPLEMENTATION_PLAN.md:174) |
| Notifications | in-app queue | StudentCreated/ParentCreated → notification docs (event-queue-map.ts:28,37) |
05 — Screen Inventory (Students Module)
- 1. Students List (Roster) —
/students - 2. Student Detail —
/students/:id - 3. Create Student Wizard —
/students/add - 4. Bulk Import Wizard —
/students/import - 5. Transfer Form —
/students/:id/transfer - 6. Enroll Form (sheet) — from detail
- 7. Documents List + Upload — detail tab + sheet
- 8. Profile Photo Upload (identity) — via Users module
- 9. Parents Tab (detail) + Link Sheet
- 10. Graduate / Archive / Restore / Delete dialogs
- Shared components used
- Module-specific components (07_Component_Library.md)
- Analytics events (proposed)
Every screen of the Students module with intent, route, composition, states and permissions. Shared components come from 00-shared/03; module-specific components are defined in 07_Component_Library.md. States legend: idle / loading / success / empty / error / disabled / permission. Analytics events
(proposed).
1. Students List (Roster) — /students
| Field | Detail |
|---|---|
| Purpose | Find, filter, open and batch-manage the roster |
| Entry | Bottom nav "Students"; deep link; after import/create returns |
| Source | GET /students (paginated data[] + meta) — student.controller.ts:41-43; find() ignores q/sort (OQ-2) |
| Composition | AppSearchBar, status/class/year filter chips (client-side), list of StudentListTile (avatar from user, name, admission number, class label, status badge), infinite scroll (meta.hasNext) |
| FAB | + Add student (visible iff student.create) |
| Row menu | View, Edit, Transfer, Graduate, Archive, Restore, Delete (contextual by status: Restore only when archived; Graduate/Transfer only when active) |
| States | loading → AppSkeleton list; empty → AppEmptyState ("No students yet — add or import"); error → AppErrorState with retry; offline → cached list + banner |
| Keyboard (desktop) | Ctrl+F focus search, N new, Esc clears filters |
| Analytics | students.list.view, students.list.search, students.list.filter, students.list.open (proposed) |
| a11y | Search results count live-region; "item X of Y" list announcements (00-shared/09 §5) |
2. Student Detail — /students/:id
| Field | Detail |
|---|---|
| Purpose | One student: identity + status + per-domain tabs |
| Source | GET /students/:id (student.controller.ts:44-46); tabs lazy-load their own endpoints |
| Composition | Header card (avatar, name, admission number mono, status badge, class + academic year chips, campus/house if set), action bar (Edit / Transfer / Graduate / Archive / Restore / Delete), AppTabs: Profile · Attendance · Fees · Results · Documents · History |
| Tab sources | Profile = GET /students/:id; Documents = GET /students/:id/documents (createdAt desc); History = GET /students/:id/academic-history (joinedAt desc) + GET /students/:id/enrollments (active); Attendance/Fees/Results = other modules (forward-looking) |
| States | detail skeleton → content; tab-level skeletons; tab error isolated (tab shows AppErrorState compact, rest of screen intact) |
| a11y | Tabs announced with badges; header actions labelled |
| Analytics | students.detail.view, students.detail.tab.{profile,documents,history,…} (proposed) |
3. Create Student Wizard — /students/add
| Field | Detail |
|---|---|
| Purpose | Admission in one guided flow (identity → academics → extras → confirm) |
| Source | POST /users then POST /students (auto-enrolls — student.service.ts:71-78) |
| Steps | 1) Identity: search/create user (firstName, lastName, email; middleName/phone optional) · 2) Academics: academic year, grade, section, class (cascading dropdowns), rollNumber, admissionType · 3) Extras: campus, house, admissionDate (default today), transportRequired, hostelRequired, medicalNotes · 4) Review + submit (shows admission number + auto-enroll note) |
| CTA per step | Next / Back; final step "Create student" |
| States | step validation errors inline; submit loading; 409 duplicate admissionNumber → jump to field; 400 → field mapping; offline → blocked |
| Duplicate guard | admission number pre-check against loaded roster client-side; server 409 is authoritative |
| Analytics | students.create.start, students.create.step_{n}, students.create.success, students.create.failure(code) (proposed) |
4. Bulk Import Wizard — /students/import
| Field | Detail |
|---|---|
| Purpose | CSV admissions batch with template + report |
| Source | GET /bulk/export/students (template), POST /bulk/import/students (multipart file) |
| Steps | 1) Template: download CSV (columns: admissionNumber, rollNumber, status, admissionDate — students-import.adapter.ts:87-98); show required column list · 2) File pick: accept .csv; client pre-parse preview (isolate — 00-shared/11 §13) · 3) Upload: progress bar (request is synchronous server-side — UI simulates progress; OQ-12) · 4) Report: {totalRows, imported, failed, errors[{rowNumber, errors}]} — grouped failure list, "Export failures" not available server-side (client can re-download template), retry with fixed CSV |
| Guards | missing file → 400; malformed/empty CSV → 400 with message |
| States | step states; upload loading; report success/failure mixed is normal (partial success) |
| Analytics | students.import.template, students.import.upload, students.import.report{imported,failed} (proposed) |
5. Transfer Form — /students/:id/transfer
| Field | Detail |
|---|---|
| Purpose | Move student to a new class (same or next academic year) |
| Source | POST /students/:id/transfer — guard: status must be active else 409 (student.service.ts:175-179) |
| Composition | Target class dropdown (filtered by academic year), academic year dropdown, optional grade/section (default = class's own), optional rollNumber, info banner "Current enrollment will be marked transferred" |
| CTA | "Transfer student" (confirm dialog first) |
| States | pre-fill from current class; submit loading; 409 → status banner + block; success → back to detail, history tab shows old enrollment transferred |
| Analytics | students.transfer.submit, `students.transfer.success |
6. Enroll Form (sheet) — from detail
| Field | Detail |
|---|---|
| Purpose | Enroll into a class for an academic year (history preserved) |
| Source | POST /students/:id/enroll — deactivates current actives, creates new ACTIVE (student.service.ts:125-140) |
| Composition | AppBottomSheet: classId (required), academicYearId (required), rollNumber (optional) |
| CTA | "Enroll" — warning text: existing active enrollments become transferred |
| Note | Same backend as transfer minus profile fields; used for re-enrollment/next-year rollover |
7. Documents List + Upload — detail tab + sheet
| Field | Detail |
|---|---|
| Purpose | Per-student files: birth certificate, reports, transfers |
| Source | GET /students/:id/documents (createdAt desc — student.service.ts:273-279); upload POST /students/:id/documents multipart file + optional category |
| Composition | List of AppAttachmentTile (fileName, size, mimeType, category chip, uploadedBy label, createdAt); FAB/sheet + Upload: file picker + category AppDropdown (free values — no enum server-side; upload-student-document.dto.ts:4-9) |
| States | empty → "No documents yet"; upload progress tile; error 400/5xx |
| a11y | Attachment labels with type + size |
| Notes | No download endpoint returns bytes today (only fileId metadata; local provider exposes /api/v1/files/<tenant>/<uuid>--<name> path — OQ-9). Preview/Download actions are (planned). |
8. Profile Photo Upload (identity) — via Users module
| Field | Detail |
|---|---|
| Purpose | Student avatar (stored on the user record, not the student) |
| Source | POST /users/:id/avatar multipart file (users.controller.ts:95-102) — surfaced inside student detail header (forward-looking) |
| Composition | avatar tap → picker → upload tile → header refresh |
| States | uploading spinner; error snackbar |
9. Parents Tab (detail) + Link Sheet
| Field | Detail |
|---|---|
| Purpose | Manage guardians |
| Source | GET /parents/link/student/:studentId (list links — parent.controller.ts:53-57); create parent POST /parents (parent.controller.ts:29-31); link POST /parents/link/:studentId (parent.controller.ts:47-52); unlink DELETE /parents/link/:linkId (parent.controller.ts:58-60) |
| Composition | Link cards: parent name (via parent→user), relationship chip (mother/father/guardian/grandparent/relative/foster_parent), primary-guardian toggle badge, financial responsibility + pickup flags, emergency priority number |
| Link sheet | Pick existing parent (search) or create new (user + parent profile: occupation, company, annualIncome, relationshipNotes, pickupAuthorization — create-parent.dto.ts); required relationship selector; optional toggles |
| Guards | 409 duplicate parent-per-user; 404 unknown student; parent existence not checked at link time (OQ-11) |
10. Graduate / Archive / Restore / Delete dialogs
| Field | Detail |
|---|---|
| Graduate | Confirm → POST /students/:id/graduate; 409 if already graduated; result: status badge graduated |
| Archive | Confirm ("hidden from roster, can restore later") → POST /students/:id/archive; 409 if already archived |
| Restore | POST /students/:id/restore → status active; item reappears in list |
| Delete | Confirm ("removes from roster; academic records retained") → DELETE /students/:id soft delete (student.service.ts:157-168); 404 if already gone |
| a11y | Dialogs trap focus; destructive actions use error colors |
Shared components used
AppSearchBar, AppChips, AppListTile, AppAvatar, AppBadge, AppTabs,
AppSkeleton, AppEmptyState, AppErrorState, AppOfflineBanner, AppFAB,
AppButton, AppDialog, AppBottomSheet, AppMenu, AppTextField,
AppDropdown, AppDatePicker, AppSwitch, AppAttachmentTile, AppStepper,
AppSnackbar, AppInfoRow, AppCard.
Module-specific components (07_Component_Library.md)
StudentListTile, StatusBadge, StudentHeader, AcademicHistoryTimeline,
ImportReportCard, RelationshipChip, GuardianLinkCard.
Analytics events (proposed)
students.list.{view,search,filter,open}, students.create.{start,step,success,failure},
students.detail.{view,tab}, students.transfer.{submit,success,failure},
students.enroll.{submit,success,failure}, students.document.{upload,open},
students.import.{template,upload,report}, students.graduate/archive/restore/delete.{confirm,success}.
06 — Screen Specifications (Students Module)
- 1. Students List (Roster)
- 2. Student Detail
- 3. Create Student Wizard (
/students/add) - 4. Transfer Form (
/students/:id/transfer) - 5. Enroll Sheet (detail → "Enroll")
- 6. Documents Upload Sheet
- 7. Bulk Import Wizard (
/students/import) - 8. Link Parent Sheet (detail → Parents tab → +)
- 9. Graduate / Archive / Restore / Delete dialogs
- Shared layout rules applied
Production-grade layout specs for the core screens. Wire-accurate to the API (12_API_Mapping.md); tokens from 00-shared/02; components from 00-shared/03 and 07_Component_Library.md. Grid: 4 dp. Breakpoints: phone < 600, tablet 600–839, landscape 840–1199, desktop ≥ 1200 (00-shared/02 §8).
1. Students List (Roster)
Layout (phone)
┌──────────────────────────────────────────────┐
│ AppBar: "Students" [search] [menu] │ 64
├──────────────────────────────────────────────┤
│ AppSearchBar (debounce 300 ms) │ 56 (sticky)
│ Filter chips row (horizontal scroll) │ 40
│ [All][Active][Graduated][Archived] [Class▾][Year▾]
├──────────────────────────────────────────────┤
│ result count line (bodySmall, onSurfaceVariant) │ 24
│ ┌──────────────────────────────────────────┐ │
│ │ StudentListTile │ │ 72 (avatar 40, name titleMedium,
│ │ avatar name · ADM-2026-001 [Active]│ │ admission number mono bodySmall,
│ │ Grade 5 · A · 2025-26 chevron │ │ class+year bodySmall, StatusBadge)
│ └──────────────────────────────────────────┘ │
│ … (n rows; infinite scroll → bottom spinner) │
│ [end of list footer] │
├──────────────────────────────────────────────┤
│ FAB: + Add student (extended, heroTag unique)│
└──────────────────────────────────────────────┘
- Padding: horizontal 16 (phone) / 24 (tablet+). Row min height 56; avatar 40.
- Sticky header group: search + chips scroll away or stick? — stick search bar,
chips scroll with content (desktop keeps all sticky,
e-2). - Tablet 600–839: 2-column list (cards,
surfaceContainerLow, r-lg). - Desktop ≥ 1200: master-detail — list left (320–400 dp), detail pane right;
row tap selects (indicator
secondaryContainer).
Behaviour rules
- Search:
AppSearchBardebounce 300 ms → client filter over loaded pages (serverqignored — OQ-2). While searching, chips disabled except Status. - Infinite scroll: load next page when last tile visible; append;
meta.hasNextguard (metashape frompagination-query.dto.ts:32-39). - Pull-to-refresh: reload page 1 + reset.
- Row menu visibility by status:
active→ View, Edit, Transfer, Graduate, Archive, Deletegraduated→ View, Edit, Restore* (no server un-graduate; Restore = archive→active only — omit; graduated only Edit, Delete), Archive, Deletearchived→ View, Edit, Restore, Delete- (see
01 §12 OQ-5/6— graduated is terminal; no action maps to it)
- Empty states: no data →
AppEmptyState"No students yet — Add or Import"; filtered empty → "No students match — clear filters" withClearaction. - Error:
AppErrorStatecode+retry; offline: banner + cached rows.
States matrix
| State | Render |
|---|---|
| initial/loading | 6 × AppSkeleton list rows |
| success+data | rows + meta count + infinite scroll |
| success+empty | AppEmptyState |
| error | AppErrorState(code, onRetry) |
| loadingMore | bottom LinearProgressIndicator (indeterminate, m-instant fade) |
| offline | rows from cache + AppOfflineBanner; FAB disabled? no — create blocked with snackbar |
Accessibility
- Live region: "24 students" / "0 results".
- Status never color-only: badge = icon + text (✓ Active etc. —
00-shared/09 §9). - Search field label "Search students".
2. Student Detail
Layout (phone, tabbed)
┌──────────────────────────────────────────────┐
│ AppBar: [←] Meera Sharma [⋯ overflow] │ 64
├──────────────────────────────────────────────┤
│ Header card (e-1, padding 16) │
│ avatar 64 Meera Sharma (titleLarge) │
│ ADM-2026-0142 [✓ Active] │
│ Grade 6 · B · 2025-26 (chips) │
│ campus: Main · house: Red │
│ actions: [Edit][Transfer] (filledTonal) │
│ [Graduate][Archive][Delete] (menu) │
├──────────────────────────────────────────────┤
│ TabBar: Profile|Attendance|Fees|Results| │
│ Documents|History (scrollable) │
├──────────────────────────────────────────────┤
│ Tab content (lazy-load per tab) │
└──────────────────────────────────────────────┘
- Tablet/desktop: header card + tabs in right pane of master-detail; two-column info grid.
- Overflow menu per status (same rules as list row menu).
Tab: Profile
Profile
├─ Identity (from linked user — read-only here)
│ name, email, phone, DOB, gender, avatar (AppInfoRow rows)
│ edit hint: "Identity is managed under User profile" (bodySmall)
├─ Academic
│ admission number (mono), roll number, admission type chip
│ admission date, class, grade, section, academic year, campus, house
├─ Flags
│ transport required ✓/✗, hostel required ✓/✗ (AppSwitch read-only)
├─ Medical notes (card, bodyMedium)
└─ Metadata (expandable, mono JSON — admin only, `(proposed)`)
Tab: Documents
Documents
├─ [+ Upload] (button top-right of tab, or FAB when tab active)
├─ Attachment tiles (createdAt desc, `student.service.ts:273-279`)
│ icon by mimeType (pdf/image/other), fileName (titleMedium),
│ "2.4 MB · category: birth_certificate · by Priya · Aug 2" (bodySmall)
│ actions: Preview `(planned)`, Share `(planned)` — blocked by OQ-9
└─ empty: "No documents yet — upload a birth certificate, transfer papers…"
Tab: History
History (GET /students/:id/academic-history — joinedAt desc)
├─ Timeline (AppAcademicHistoryTimeline)
│ ▸ 2025-26 · Grade 6-B joined Aug 2, 2025 [ACTIVE]
│ ▸ 2024-25 · Grade 5-A joined Apr 1, 2025 [transferred]
│ left Aug 1, 2025
│ each node: year/class title, joinedAt/leftAt dates, EnrollmentStatus badge
└─ Note: current active enrollment also surfaces via /enrollments (active filter)
Tab: Attendance / Fees / Results (forward-looking)
Owned by their modules; here: embedded lazy tab shells that call those modules'
read APIs (per PLAN.md 5.4/7.x/8.x flows). If module API not implemented →
tab shows (planned) empty-state placeholder. Do not block rendering of other tabs.
Behaviour rules
- Tabs lazy-load on first activation; state kept alive (
TabBarView+ keep-alive). - After any action (edit/transfer/enroll/upload/graduate/archive/restore) refresh profile + history + badges, snackbar confirmation.
- 404 on load →
AppErrorState"Student not found" + back; treat as deleted.
3. Create Student Wizard (/students/add)
Step model (AppStepper — horizontal phone, vertical desktop)
Step 1 — Identity
Identity (source: POST /users — create-student.dto via users module)
├─ firstName* (text, autofill given-name)
├─ middleName? (text)
├─ lastName* (text, autofill family-name)
├─ email* (email keyboard, autofill email) — unique per tenant
├─ phone? (tel)
└─ "Find existing user" — search over GET /users?q= (pick → skips creation)
Step 2 — Academics
Academics (source: cascading GET /academic-years → /grades → /sections/by-grade/:gradeId → /classes?by-year)
├─ academic year* (AppDropdown — only ACTIVE/UPCOMING shown by status field)
├─ grade* (AppDropdown)
├─ section* (AppDropdown — filtered by grade)
├─ class* (AppDropdown — filtered by grade+section+year; shows capacity "Grade 5 · A (32/40)")
├─ roll number? (text, mono)
└─ admission type? (segmented: new | transfer | promoted — matches enum student.schema.ts:47)
Step 3 — Extras
Extras
├─ admission date? (AppDatePicker — default today; max = today; server defaults new Date() student.service.ts:66-68)
├─ campus? (AppDropdown)
├─ house? (AppDropdown)
├─ transport required? (AppSwitch)
├─ hostel required? (AppSwitch)
└─ medical notes? (multiline)
Step 4 — Review & submit
Review
├─ summary card (identity, class path, flags)
├─ note: "Student is created as ACTIVE and auto-enrolled in Grade 5 · A" (banner)
└─ CTA: "Create student" (loading spinner)
Submission sequence (exact)
POST /users {firstName,lastName,email,phone?}→ 200user._id(or 409 → "email already registered" → switch to search-existing step).POST /students {userId, admissionNumber, rollNumber?, academicYearId, campusId?, gradeId, sectionId, classId, houseId?, admissionDate?, admissionType?, transportRequired?, hostelRequired?, medicalNotes?}→ 200 student (auto-enrolled).- Navigate
/students/:id+ snackbar "Student created".
Error mapping
| Error | UI |
|---|---|
| 400 validation | field errors (first invalid field focused) |
| 409 duplicate admission | admission-number field inline "already exists" + suggest search |
| 409 duplicate email (users) | step 1 inline, offer reuse |
| 429 | countdown banner, disable CTA |
| offline | block submit, banner |
States
step validated on Next; back preserves form; submit = step 4 loading; failure stays on step 4 with error summary. Interrupted flow (app kill) → no draft persistence (YAGNI; re-enter).
4. Transfer Form (/students/:id/transfer)
Transfer — Meera Sharma (Grade 6-B, active)
Banner: "Current enrollment will be marked as transferred. History is preserved."
├─ class* (AppDropdown — classes for chosen year)
├─ academic year* (AppDropdown)
├─ grade? (AppDropdown — default: target class's grade) transfer-student.dto.ts:13-19
├─ section? (AppDropdown — default: target class's section) transfer-student.dto.ts:20-25
└─ roll number? (text)
CTA: [Transfer student] (confirm dialog: "Transfer to Grade 7-A?")
- Pre-submit validation client-side: target must differ from current
(
classId/academicYearIdboth equal current → block with inline message). - Server guard: status ≠
active→ 409Cannot transfer a student with status "x".→ screen switches to read-only + status banner (student.service.ts:175-179). - Success → detail refresh; history tab shows old enrollment
transferred+leftAt(class-enrollment.schema.ts:30-31).
5. Enroll Sheet (detail → "Enroll")
AppBottomSheet (half, drag handle)
Enroll in class
Banner: "Any currently active enrollment becomes transferred."
├─ class* (AppDropdown — filterable by year)
├─ academic year* (AppDropdown)
└─ roll number? (text)
CTA: [Enroll] → POST /students/:id/enroll → snackbar + refresh
6. Documents Upload Sheet
AppBottomSheet
Upload document — Meera Sharma
├─ file picker row (name + size + type after selection; camera option phone)
├─ category? (AppDropdown with suggestions: birth_certificate, transfer_certificate,
│ report_card, medical, id_proof, other — free text allowed, server accepts any string)
└─ CTA: [Upload] → multipart POST /students/:id/documents (field "file")
- Client-side guards (server has none — OQ-9): size ≤ 10 MB
(proposed), mime allowlist pdf/jpg/png/webp(proposed); violation → inline error before upload. - Upload progress: determinate bar; on 200 → tile appears at top (createdAt desc);
on network loss mid-upload → error + retry tile (
00-shared/10 §2).
7. Bulk Import Wizard (/students/import)
Step 1 — Template
Import students from CSV
├─ info card: required columns — firstName*, lastName*, email*,
│ admissionNumber*, grade*, section*, academicYear*;
│ optional — rollNumber (students-import.adapter.ts:17-26)
├─ [Download template] → GET /bulk/export/students (text/csv attachment,
│ filename "students.csv" — bulk.controller.ts:50-60) — exported columns differ:
│ admissionNumber, rollNumber, status, admissionDate (adapter exportRows 87-98)
└─ [Next]
Step 2 — File
├─ file picker (.csv only)
├─ client pre-parse (isolate — 00-shared/11 §13): header check + column presence;
│ warn on missing required columns BEFORE upload
└─ [Import CSV]
Step 3 — Progress (uploading)
Uploading… (indeterminate bar; request synchronous server-side — OQ-12)
"Processing N rows — this may take a minute for large files"
Step 4 — Report
Import report
┌──────────────────────────────┐
│ Imported Failed Total │ (ImportReportCard: 3 stat tiles)
│ 997 3 1000 │
└──────────────────────────────┘
Failed rows (grouped list):
Row 42 — Missing required column "section".
Row 87 — Email "x@y" already registered.
Row 120 — No class found for grade "5" section "A".
(every error string server-side: students-import.adapter.ts:43-57,116-141)
CTA: [Done] → back to list (refresh) · [Fix and re-import] → step 2
- Partial success is the normal state — never show "failed" as an error page.
8. Link Parent Sheet (detail → Parents tab → +)
Link guardian — Meera Sharma
├─ Guardian: [search existing parent] or [create new]
│ create new: (user) firstName*, lastName*, email*, phone?
│ (parent) occupation?, company?, annualIncome?, relationshipNotes?,
│ pickupAuthorization? (create-parent.dto.ts)
├─ relationship* (AppDropdown — required, enum: mother|father|guardian|
│ grandparent|relative|foster_parent — student-parent-link.schema.ts:7-14)
├─ primary guardian? (AppSwitch — isPrimaryGuardian)
├─ financial responsibility? (AppSwitch)
├─ pickup allowed? (AppSwitch — default TRUE, link dto default)
└─ emergency priority? (AppSlider 0–10, default 0)
CTA: [Link] → POST /parents/link/:studentId
- New-parent branch sequence:
POST /users→POST /parents(409 if parent profile exists for user) → link. - Unlink: row menu → confirm →
DELETE /parents/link/:linkId(404 → treat removed).
9. Graduate / Archive / Restore / Delete dialogs
| Dialog | Copy (title / body) | Confirm label | Endpoint |
|---|---|---|---|
| Graduate | "Graduate Meera Sharma?" / "Marks the student as graduated. This cannot be undone by the UI (no un-graduate endpoint)." | Graduate (destructive) | POST /students/:id/graduate |
| Archive | "Archive Meera Sharma?" / "Hidden from the roster. You can restore anytime." | Archive | POST /students/:id/archive |
| Restore | "Restore Meera Sharma?" / "Returns the student to active status." | Restore | POST /students/:id/restore |
| Delete | "Delete Meera Sharma?" / "Soft-deletes the record — excluded from all lists. Documents and history remain." | Delete (destructive) | DELETE /students/:id |
409 handling: graduate twice / archive twice / restore twice → server 409
"Student is already graduated/archived/active." → snackbar, state refresh
(student.service.ts:207-209,228-230).
Shared layout rules applied
- One
headlineMediumper screen title (00-shared/09 §11). - All lists
ListView.builder; infinite scroll with end-of-list footer. - Keyboard: phone bottom-inset avoidance on all forms.
- Motion: screen enter
m-baseslide; sheetm-basescale+fade; success snackbar < 3 s; error ≥ 4 s (00-shared/03 A). - Dark mode: every surface tokenized; badges keep icon+text (no color-only).
07 — Component Library (Students Module)
- 1. StudentListTile
- 2. StatusBadge
- 3. StudentHeader (detail)
- 4. AcademicHistoryTimeline
- 5. ImportReportCard
- 6. RelationshipChip
- 7. GuardianLinkCard
- 8. ClassSelectCascade (wizard helper)
- 9. DocumentUploadTile
- 10. RosterFilterBar
- Component reuse map
- Golden-test expectation
Module-specific widgets built on 00-shared/03 components. Each entry: purpose, props, states, tokens, Flutter mapping. Analytics events follow the
(proposed)convention from 00-shared/10 §8.
1. StudentListTile
- Purpose: roster row (list screen + master-detail pane).
- Props:
student(id, admissionNumber, rollNumber, status, class/grade/section labels, academicYear label),user(name, avatarUrl),onTap,menuItems,selected(desktop master-detail). - States: default / selected (
secondaryContainerindicator) / disabled (none needed — no student is un-openable). - Layout: avatar 40 (
AppAvatar, fallback initials), title = display name (titleMedium), subtitle =ADM-2026-0142 · Grade 6 · B(bodySmallmono for the admission number), trailing =StatusBadge+ chevron/menu (AppMenu). - Tokens: list row ≥ 56 (00-shared/02 §9),
surface,e-0. - Flutter:
ListTilewrapped withMenuAnchor;Herotag =student-{id}(avatar → detail header, 00-shared/08 §4). - A11y: combined label "Meera Sharma, active, Grade 6 B"; row tap semantics.
2. StatusBadge
- Purpose: student + enrollment status chips; never color-only (00-shared/09 §9).
- Props:
status(StudentStatus | EnrollmentStatus),size(sm 20 / md 24). - Mapping (from
student.schema.ts:7-13,class-enrollment.schema.ts:7-11):
| Status | Icon | Color role | Label |
|---|---|---|---|
active | ✓ check_circle (filled) | secondary/success | Active |
inactive | remove_circle_outline | onSurfaceVariant | Inactive |
graduated | school (filled) | tertiary | Graduated |
transferred | swap_horiz (filled) | onSurfaceVariant | Transferred |
archived | archive (filled) | error-muted / outline | Archived |
- Flutter:
Badge/customContainer+ icon;AppBadgewhen icon+text too wide.
3. StudentHeader (detail)
- Purpose: detail hero block (avatar, identity, class path, status, actions).
- Props:
student,user,status,onEdit,onTransfer,onGraduate,onArchive,onRestore,onDelete,permissions(hide actions withoutstudent.update/student.delete). - Layout: avatar 64 (
AppAvatar), nametitleLarge, admission numbermono,StatusBadge, chips for class path (Grade 6 · B · 2025-26), campus/house when set; action row:FilledButton.tonalEdit/Transfer (≤ 2), rest inAppMenu. - Tokens:
surfaceContainerLow,e-1,r-lg, padding 16. - Flutter:
Card.filled+Row/Wrap(wraps on phone width).
4. AcademicHistoryTimeline
- Purpose: enrollment history visualization (append-only trail,
COLLECTIONS.md:1674-1677). - Props:
enrollments[](classId, academicYearId, rollNumber, joinedAt, leftAt, status) — already sortedjoinedAt descbystudent.service.ts:281-287. - Node: year + class label (
titleMedium),joinedAt → leftAtdates (bodySmall,Intl-formatted),EnrollmentStatusbadge; connector lineoutlineVariant; active node dotprimary(pulsingm-fast), past nodesonSurfaceVariant. - States: empty → inline
AppEmptyStatecompact ("No enrollment history yet — created students are auto-enrolled once"). - A11y: timeline as list semantics; each node one row.
- Flutter: custom
ColumnofRows withSemantics.
5. ImportReportCard
- Purpose: bulk-import result summary (from
ImportReport—import-adapter.interface.ts:14-25). - Props:
report {entity, totalRows, imported, failed, errors[]}. - Layout: 3
AppStatTile(Imported / Failed / Total) + collapsible failed-rows list: "Row 42 — Missing required column "section"." (each error string verbatim from server). - States: all-imported (success tint), partial (warning tint), all-failed (error tint) — icons + text, not color alone.
- A11y: live-region announces "997 of 1000 imported".
6. RelationshipChip
- Purpose: parent-link relationship tag (enum values).
- Props:
relationship— mother | father | guardian | grandparent | relative | foster_parent (student-parent-link.schema.ts:7-14). - Rendering:
InputChip-style with icon (mother = female icon, father = male, guardian = shield, grandparent = family, relative = link, foster_parent = home); text label capitalized. - Tokens:
surfaceVariantfill,labelMedium.
7. GuardianLinkCard
- Purpose: one linked parent (Parents tab).
- Props:
link {parentId, relationship, isPrimaryGuardian, financialResponsibility, pickupAllowed, emergencyPriority}+ parent/user names +onUnlink,onEditLink(planned)— no link-update endpoint exists; edit = unlink + re-link. - Layout:
AppCard: avatar + name (titleMedium),RelationshipChip, badge "Primary guardian" (filledprimaryContainer) whenisPrimaryGuardian, flag rows (financial responsibility ✓, pickup allowed ✓/✗, priority n) viaAppInfoRow, trailingAppMenu(Unlink). - Empty: "No guardians linked — add a parent or guardian".
8. ClassSelectCascade (wizard helper)
- Purpose: year → grade → section → class cascading pickers with capacity info.
- Props:
onChanged(ClassSelection{academicYearId, gradeId, sectionId, classId}),initial?,loading. - Sources (called through repository):
GET /academic-years,GET /grades,GET /sections/by-grade/:gradeId,GET /classes/by-year/:academicYearId(and client-side grade+section filtering for class list when by-year is coarse — noteclass.repository/controller only index by year server-side;academics/controllers/class.controller.ts:30-33). - States: per-level loading skeleton (
AppSkeletonline), empty level ("No classes yet for this year — set up Academics first"). - Tokens: stacked
AppDropdowns, gap 16.
9. DocumentUploadTile
- Purpose: upload-in-progress/error attachment row.
- Props:
fileName,size,progress?,error?,onRetry,onCancel. - States: queued → uploading (determinate
LinearProgressIndicator) → done (swap toAppAttachmentTile) → error (error text + Retry/Cancel). - A11y: progress announced via live region.
10. RosterFilterBar
- Purpose: status/class/year filter chips + result count.
- Props:
statusFilter,classFilter,yearFilter,onChanged,resultCount,loading. - Behaviour: status chips fixed set (All/Active/Graduated/Archived); class/year
are
AppDropdownchips; count linebodySmall;Clearappears when any filter active. Client-side only until server filters land (OQ-8).
Component reuse map
| Shared component (00-shared/03) | Used by |
|---|---|
AppStepper | Create wizard, import wizard |
AppAttachmentTile | Documents tab |
AppTabs | Student detail |
AppStatTile | Import report |
AppSlider | Emergency priority (link sheet) |
AppBottomSheet | Enroll, upload, link-parent |
AppDialog | Graduate/Archive/Restore/Delete/Transfer confirm |
AppMenu | Row + header overflow actions |
Golden-test expectation
Each module component: phone/tablet/desktop + dark mode (00-shared/03 G, 00-shared/10 §9).
08 — Form Specifications (Students Module)
- 1. Create Student (wizard step 2–4 →
POST /students) - 2. Enroll (
POST /students/:id/enroll) - 3. Transfer (
POST /students/:id/transfer) - 4. Update student (
PATCH /students/:id) - 5. Upload document (
POST /students/:id/documents) - 6. CSV bulk import (
POST /bulk/import/students) - 7. Create parent (
POST /parents) - 8. Link parent (
POST /parents/link/:studentId) - 9. Avatar upload (
POST /users/:id/avatar) - Validation UX rules (all forms)
Every field of every form, exactly mapped to the server DTOs. Types/validation from the DTO files (class-validator decorators); enums from schemas. All fields not listed do not exist server-side.
*= required by backend.
1. Create Student (wizard step 2–4 → POST /students)
Payload DTO: create-student.dto.ts. Identity step targets POST /users
(users.controller.ts:36-40).
| Field | Type / rules (server) | Source | Client input | Notes |
|---|---|---|---|---|
userId* | MongoId | create-student.dto.ts:6-7 | hidden (from step 1 user) | must exist; user created/reused first |
admissionNumber* | string | create-student.dto.ts:9-11 | AppTextField (uppercase, no spaces) | unique per tenant → 409 (student.service.ts:56-62); client pre-check against roster |
rollNumber? | string | create-student.dto.ts:13-16 | text, mono | |
academicYearId* | MongoId | create-student.dto.ts:18-20 | AppDropdown (academic years) | |
campusId? | MongoId | create-student.dto.ts:22-25 | AppDropdown | |
gradeId* | MongoId | create-student.dto.ts:27-29 | AppDropdown | |
sectionId* | MongoId | create-student.dto.ts:31-33 | AppDropdown (by grade) | |
classId* | MongoId | create-student.dto.ts:35-37 | AppDropdown (by grade+section+year) | auto-enrolled here (student.service.ts:71-78) |
houseId? | MongoId | create-student.dto.ts:39-42 | AppDropdown | |
admissionDate? | ISO date string | create-student.dto.ts:44-47 | AppDatePicker (≤ today) | omitted → today (student.service.ts:66-68) |
admissionType? | string enum new|transfer|promoted | create-student.dto.ts:49-52; student.schema.ts:47 | segmented buttons | free string accepted by DTO; UI constrains |
transportRequired? | boolean | create-student.dto.ts:54-56 | AppSwitch | schema default false |
hostelRequired? | boolean | create-student.dto.ts:57-59 | AppSwitch | schema default false |
medicalNotes? | string | create-student.dto.ts:61-65 | multiline |
Identity step (POST /users) — user schema fields (user.schema.ts:16-35):
firstName* (required), middleName?, lastName* (required), displayName?,
email* (unique per tenant → 409; lowercase), phone?. UI submits only
firstName, lastName, email, phone?.
Server status on create: always active (student.service.ts:65), admissionType
default new, enrollment ACTIVE with joinedAt = now.
2. Enroll (POST /students/:id/enroll)
DTO: enroll-student.dto.ts.
| Field | Type / rules | Source |
|---|---|---|
classId* | MongoId | enroll-student.dto.ts:5-7 |
academicYearId* | MongoId | enroll-student.dto.ts:9-11 |
rollNumber? | string | enroll-student.dto.ts:13-16 |
Side effect banner: existing ACTIVE enrollments → transferred + leftAt
(student.service.ts:125-139).
3. Transfer (POST /students/:id/transfer)
DTO: transfer-student.dto.ts.
| Field | Type / rules | Source | Client |
|---|---|---|---|
classId* | MongoId | transfer-student.dto.ts:5-7 | dropdown |
academicYearId* | MongoId | transfer-student.dto.ts:9-11 | dropdown |
gradeId? | MongoId — "defaults to class grade" | transfer-student.dto.ts:13-19 | dropdown, prefilled from class |
sectionId? | MongoId — "defaults to class section" | transfer-student.dto.ts:20-25 | dropdown, prefilled from class |
rollNumber? | string | transfer-student.dto.ts:27-30 | text |
Guard: student status must be active (student.service.ts:175-179).
4. Update student (PATCH /students/:id)
DTO: update-student.dto.ts — every field optional mirror of create plus:
status? (plain string, no enum validation — update-student.dto.ts:60-63;
UI must only send valid StudentStatus values), metadata? (object,
update-student.dto.ts:78-80). Note: PATCH with $set writes only provided
fields (student.service.ts:144). Client edit form = create fields minus class
path (class changes go through transfer/enroll) plus status only via lifecycle
actions, not the form.
5. Upload document (POST /students/:id/documents)
| Part | Type | Source |
|---|---|---|
file* | multipart file — field name exactly file | student.controller.ts:83; MulterFile{buffer,originalname,mimetype,size} |
category? | string (body, multipart text field) | upload-student-document.dto.ts:4-9 |
Server has no size/mime limits (OQ-9). Client (proposed) limits: ≤ 10 MB,
mime ∈ {pdf, jpg, png, webp}. uploadedBy = actor userId (student.service.ts:269).
6. CSV bulk import (POST /bulk/import/students)
Adapter columns (students-import.adapter.ts:17-26):
| CSV column | Required | Validation / resolution | Error string (exact) |
|---|---|---|---|
firstName | ✓ | passed to user create | "Missing required column "firstName"." |
lastName | ✓ | passed to user create | "Missing required column "lastName"." |
email | ✓ | regex ^[^\s@]+@[^\s@]+\.[^\s@]+$ + uniqueness | "Invalid email format." / "Email "{email}" already registered." |
admissionNumber | ✓ | tenant-unique | "Missing required column "admissionNumber"." / "Admission number "{n}" already exists." |
grade | ✓ | matched by name or code (students-import.adapter.ts:118-120) | "Grade "{g}" not found." |
section | ✓ | matched by name | "Section "{s}" not found." |
academicYear | ✓ | matched by name | "Academic year "{y}" not found." |
rollNumber | — | passthrough | — |
Derived resolution: class = first class matching {gradeId, sectionId, academicYearId}
(students-import.adapter.ts:131-141) — error "No class found for grade "{g}"
section "{s}".".
Parsing rules (bulk-import.service.ts:26-35): header row required (first row
= column names), skip_empty_lines, trim, columns: true. Empty file → 400
"CSV must include a header row and data." Malformed → 400 "Malformed CSV: could not
parse file." Row numbering: index + 2 (header = row 1).
Export template (GET /bulk/export/students, students-import.adapter.ts:87-98):
columns admissionNumber, rollNumber, status, admissionDate — sorted by
admissionNumber asc. This is an export format, not an import template —
flagged in UI (import columns differ).
7. Create parent (POST /parents)
DTO: create-parent.dto.ts.
| Field | Type / rules | Source |
|---|---|---|
userId* | MongoId | create-parent.dto.ts:5-7 |
occupation? | string | create-parent.dto.ts:9-12 |
company? | string | create-parent.dto.ts:13-16 |
annualIncome? | number | create-parent.dto.ts:17-20 |
relationshipNotes? | string | create-parent.dto.ts:21-24 |
emergencyContactPriority? | number | create-parent.dto.ts:25-28 |
pickupAuthorization? | boolean | create-parent.dto.ts:29-32 |
Guard: one parent profile per userId → 409 "Parent profile already exists for
this user." (parent.service.ts:30-34).
8. Link parent (POST /parents/link/:studentId)
DTO: link-parent.dto.ts.
| Field | Type / rules | Source |
|---|---|---|
parentId* | MongoId | link-parent.dto.ts:5-7 |
relationship* | string enum: mother|father|guardian|grandparent|relative|foster_parent | link-parent.dto.ts:9-20; student-parent-link.schema.ts:7-14 |
isPrimaryGuardian? | boolean (schema default false) | link-parent.dto.ts:22-24 |
financialResponsibility? | boolean (default false) | link-parent.dto.ts:26-28 |
pickupAllowed? | boolean (schema default true) | link-parent.dto.ts:30-32 |
emergencyPriority? | number (schema default 0) | link-parent.dto.ts:34-36 |
Student existence checked (student-parent-link.service.ts:27); parent existence
not checked (OQ-11).
9. Avatar upload (POST /users/:id/avatar)
Multipart field file (users.controller.ts:96); updates avatarFileId on the
user doc (user.schema.ts:34-35).
Validation UX rules (all forms)
- Run
validatoron submit + after first error; clear on edit (00-shared/03 B). - 400
VALIDATION_ERROR→ mapdetails[].fieldto fields (00-shared/06 §5); focus first invalid (00-shared/09 §10). - 409 → inline conflict with suggested action (search existing / change admission number).
- Submit buttons: disabled while pending (anti-double-submit, 00-shared/08 §6).
09 — User Behaviour (Students Module)
- 1. Roster list
- 2. Create wizard
- 3. Transfer / Enroll
- 4. Documents
- 5. Bulk import
- 6. Parent linking
- 7. Lifecycle actions (graduate/archive/restore/delete)
- 8. Offline & connectivity
- 9. Permission-driven behaviour (client)
Behavioural rules per screen: defaults, expectations, friction points and mitigations. All server behaviour cited is exact (
student.service.ts,bulk-import.service.ts). Anything marked(planned)/(proposed)is flagged.
1. Roster list
| Rule | Detail |
|---|---|
| Default view | page 1, limit 20 (pagination-query.dto.ts:11-19); client shows status "All" |
| Ordering | server returns natural order (no sort applied — student.service.ts:104-108); client sorts loaded page by name/admission number when user picks a sort chip |
| Search expectation | user expects server-wide search (q); today client filters loaded pages only → surface honesty: helper text "Searching current results — full search coming soon" (planned) (OQ-2) |
| Refresh | RefreshIndicator reloads page 1; infinite scroll appends via meta.hasNext |
| Returning from detail | list refreshes silently in background (stale-while-revalidate) |
| Long session | statuses can change elsewhere (another admin) — pull-to-refresh is the contract; WS roster updates (forward-looking) |
2. Create wizard
| Rule | Detail |
|---|---|
| Completion expectation | "created + enrolled in one action" — exact server semantics (student.service.ts:55-93); tell the user upfront on step 4 |
| Identity discovery | reuse-existing-user is the happy path for re-admissions; new-user only when not found. 409 email → auto-suggest search |
| Admission number | suggest pattern ADM{YYYY}{seq} (example in DTO create-student.dto.ts:9); client caps length; uniqueness pre-checked + server-verified |
| Abandonment | no draft persistence (YAGNI); warn on back with unsaved changes via system back-interception |
| Double submit | CTA disabled while pending (00-shared/08 §6) |
3. Transfer / Enroll
| Rule | Detail |
|---|---|
| Expectation | "history preserved, current enrollment closed" — banner before submit (student.service.ts:125-131) |
| Same-class guard | client blocks identical classId+academicYearId (no server guard — server creates a duplicate enrollment; OQ-13) |
| Non-active student | transfer form replaced by read-only status banner (server 409 mirror, student.service.ts:175-179) |
| After success | navigate to history tab; new ACTIVE node on top, old node transferred with leftAt |
4. Documents
| Rule | Detail |
|---|---|
| Expectation | uploaded file is instantly part of the student record — true (doc created synchronously) |
| Category | free text (no server enum); suggest chips but allow custom |
| Preview | not implemented server-side (only fileId metadata; OQ-9) — do not show a dead preview button; show "Open file" only when a serving endpoint exists (planned) |
| Ordering | newest first (student.service.ts:277-278) — matches "just uploaded on top" |
5. Bulk import
| Rule | Detail |
|---|---|
| Expectation | "big batch, one report" — synchronous loop server-side (bulk-import.service.ts:45-63); 1000 rows can take a while (OQ-12) → progress step must not pretend real progress; use indeterminate + row-count readout |
| Re-import | user re-uploads the same file to retry failed rows — dedup by admission number makes fixed rows import cleanly (server checks each row) |
| Template mismatch | export CSV ≠ import CSV columns (see 08 §6) — the UI must present the import column list, not the export file |
| Partial success | normal; report card shows imported/failed/total; never an error page |
6. Parent linking
| Rule | Detail |
|---|---|
| Expectation | "link the guardian who should get calls and fee messages" — flags map to isPrimaryGuardian, financialResponsibility, pickupAllowed, emergencyPriority (link-parent.dto.ts) |
| Duplicate parent user | 409 → reuse existing parent instead of creating |
| Unlink | soft delete (student-parent-link.service.ts:38-41); history of links is not shown after unlink (deleted rows excluded by scopedFilter) |
| Edit link | no PATCH endpoint — edit = unlink + relink; tell the user ("changes require re-linking") |
7. Lifecycle actions (graduate/archive/restore/delete)
| Rule | Detail |
|---|---|
| Graduate | terminal in UI (no un-graduate endpoint; OQ-5) — copy must say so |
| Archive | reversible; archived students hidden from default roster (client filter) |
| Restore | returns to active (no restoration to graduated/transferred) |
| Delete | soft delete — "removed from all lists, history retained"; no restore UI (no restore endpoint for soft-deleted; OQ-7) |
| Idempotent taps | double-tap same action → server 409 (already graduated/archived/active) → snackbar + state refresh, no crash (student.service.ts:207-209,228-230) |
8. Offline & connectivity
| Rule | Detail |
|---|---|
| Reads | cached last-good roster + detail; AppOfflineBanner (00-shared/06 §3.7) |
| Writes | all blocked (create/enroll/transfer/upload/import/link are server-first, no offline queue defined) — guidance snackbar |
| Upload interrupted | DocumentUploadTile error state with Retry (no chunked resume — 00-shared/12 B7) |
| Import offline | blocked before file pick |
9. Permission-driven behaviour (client)
- Actions hidden when permission missing (00-shared/05 §9): create FAB needs
student.create; edit/transfer/graduate/archive/restore needstudent.update; delete needsstudent.delete; upload needsfile.upload; import/export(planned)gate — nostudent.importconstant (OQ-4). - Server currently authorizes by JWT only (
student.controller.ts:32-35) — a 403 can still appear later; treat via standard error mapping (00-shared/06 §5).
10 — Interaction Specification (Students Module)
- 1. Gestures & inputs (phone/tablet)
- 2. Desktop/keyboard
- 3. Motion choreography
- 4. Micro-interactions
- 5. Frustration controls
- 6. Haptics map (mobile; off on web/desktop and when system disabled)
- 7. Realtime interplay
Interaction details on top of the global conventions (00-shared/08). Motion tokens from 00-shared/02 §6; haptics per 00-shared/08 §3.
1. Gestures & inputs (phone/tablet)
| Gesture | Behaviour | Screen |
|---|---|---|
| Tap row | open detail (Hero flight on avatar, m-slow) | Roster |
| Long-press row | AppMenu (same as trailing ⋮) | Roster |
| Swipe left (phone) | none — roster rows keep menu only (avoid accidental archive) | Roster |
| Pull down | refresh page 1 + reset infinite scroll (RefreshIndicator, lightImpact on success) | Roster, Documents, History, Parents |
| Scroll down/up | FAB hides on scroll-down, reappears scroll-up (00-shared/03 D) | Roster |
| Tab switch | indicator slide m-base; content cross-fade m-slow | Detail |
| Back on wizard | step back; at step 1 → leave prompt if dirty | Create/Import |
| Sheet dismiss | drag handle + barrier; state preserved on dismiss | Enroll/Upload/Link |
2. Desktop/keyboard
| Input | Behaviour |
|---|---|
Ctrl+F | focus roster search |
N | open create wizard (roster) |
Esc | close sheets/menus/clear search; step back in wizards |
Enter | submit focused form field chain |
| Right-click row | context menu = long-press menu |
| Hover row | 2% surface tint m-fast; tooltip on truncated names |
| Arrows | navigate dropdown options; timeline focusable nodes |
3. Motion choreography
| Moment | Motion |
|---|---|
| List → detail | Hero avatar m-slow; page slide m-base |
| Status badge change | AnimatedSwitcher icon morph m-fast + scale pop on badge |
| New document tile | entrance m-entrance (stagger 40 ms with list) |
| Import report numbers | count-up animation m-slow (reduced-motion: static) |
| Upload progress | determinate LinearProgressIndicator m-instant color shifts |
| Snackbar confirmations | success < 3 s, error ≥ 4 s, slide m-base (00-shared/03 A) |
| Skeleton → content | cross-fade m-slow, never pop-in (00-shared/08 §4) |
| Reduced motion | all of the above → fades ≤ m-instant; no count-up, no stagger, no shimmer |
4. Micro-interactions
| Interaction | Detail |
|---|---|
| Filter chip toggle | selected = secondaryContainer fill, m-fast |
| Search | 300 ms debounce; clear button restores; results count live-region |
| Submit buttons | spinner replaces label; success → check + snackbar (00-shared/08 §5) |
| Row tap | ink ripple + 40 ms press feedback |
| Timeline node | active node dot pulses m-fast; past nodes static |
| Unlink guardian | destructive confirm heavyImpact, then row removal on server 200 only (not optimistic — server-first) |
| Lifecycle actions | confirm dialog m-base; destructive label error; heavyImpact on confirm |
5. Frustration controls
- Every network action shows pending state (button spinner / skeleton) — never a silent wait (00-shared/08 §6).
- Repeated taps disabled while pending everywhere (create/enroll/transfer/upload/ import/link).
- 429 on any action → countdown + disabled CTA (00-shared/06 §5).
- Upload retry: error tile keeps file bytes in memory, retry re-posts; cancel discards.
6. Haptics map (mobile; off on web/desktop and when system disabled)
| Event | Haptic |
|---|---|
| Create/enroll/transfer success | lightImpact |
| Server 409/422 on lifecycle action | mediumImpact |
| Long-press menu | selectionClick |
| Destructive confirm (delete/graduate/archive/unlink) | heavyImpact |
| Pull-to-refresh complete | lightImpact |
7. Realtime interplay
- No WS topics for students today (00-shared/07 §8 topics list has none for
students). Roster freshness = pull-to-refresh + re-fetch on screen focus.
WS
roster.changedtopic(forward-looking). - In-app notifications (
StudentCreated→in-appstudent-enrolled,event-queue-map.ts:28) arrive to the actor; tapping a student notification → deep link/students/:id(studylyon://students/:id)(proposed).
11 — Design System Mapping (Students Module)
- 1. Module colour usage
- 2. Typography mapping (module)
- 3. Spacing & layout tokens (module)
- 4. Radius & elevation
- 5. Icons (Material Symbols, outlined; status = filled)
- 6. Motion mapping (module)
- 7. Widget → token additions (module)
- 8. Dark-mode notes (module)
Module-specific extensions to the shared mapping (00-shared/04). Tokens from 00-shared/02; theming rules enforced (00-shared/04 §7): no literal colors, spacing, or durations in widget code.
1. Module colour usage
| Use | Light token | Dark token | Component |
|---|---|---|---|
Status active badge | secondary/secondaryContainer | same roles | StatusBadge |
Status graduated badge | tertiary/tertiaryContainer | same | StatusBadge |
Status archived badge | onSurfaceVariant on surfaceVariant + error icon | same | StatusBadge |
| Destructive actions (delete/graduate/unlink) | error | error | AppButton.destructive, dialogs |
| Primary guardian badge | primaryContainer/onPrimaryContainer | same | GuardianLinkCard |
| Active timeline node | primary (pulse) | primary | AcademicHistoryTimeline |
| Import partial-failure banner | tertiaryContainer | same | ImportReportCard |
| Filter chip selected | secondaryContainer | same | RosterFilterBar |
| Admission number / roll number / IDs | mono type token | same | list tiles, headers, forms |
| Warning banner (enrollment deactivation) | tertiaryContainer | same | transfer/enroll forms |
Status is never color-only — always icon + text (00-shared/09 §9).
2. Typography mapping (module)
| Token | Where |
|---|---|
headlineMedium | Roster screen title (AppBar titleLarge on detail), empty-state headlines |
titleLarge | Detail header student name, import report headline |
titleMedium | List tile names, timeline year/class, section headers |
bodyMedium | Form labels, info rows, banner bodies |
bodySmall | Timestamps, file sizes, result-count line, helper texts |
labelLarge | Tab labels, buttons, chips |
labelMedium | Badges, relationship chips |
mono | ADM-…, roll numbers, metadata JSON |
Numerals (priorities, counts, capacities) use tabularFigures()
(00-shared/02 §2).
3. Spacing & layout tokens (module)
| Token | Value | Use |
|---|---|---|
| Screen padding | 16 / 24 (tablet+) | all module screens |
| Card padding | 16 | header, guardian, document tiles |
| Card gap | 12 | tab content stacks |
| Section gap | 24 | profile tab sections |
| List row height | ≥ 56 (roster 72 with avatar+meta) | tiles |
| Form field gap | 16 | all forms |
| Wizard step gap | 24 | AppStepper |
| Master-detail list width | 320–400 dp | desktop ≥ 1200 |
4. Radius & elevation
| Token | Use |
|---|---|
r-lg 12 | header card, guardian card, import report card |
r-md 8 | inputs, buttons, attachment tiles |
r-full | avatars, status dot |
e-0 | roster rows (flat) |
e-1 | header card, cards, sticky search bar |
e-2 | sticky filter bar (desktop), FAB |
e-4 | dialogs (graduate/archive/delete) |
e-5 | sheets (enroll/upload/link) |
5. Icons (Material Symbols, outlined; status = filled)
| Icon | Use |
|---|---|
group / person_add | roster empty state / create |
search | roster search |
filter_alt | filter bar |
upload_file / description / picture_as_pdf | documents |
swap_horiz | transfer |
school | graduate badge |
archive / unarchive | archive / restore |
delete | delete |
family_restroom / shield | parent link sheet |
check_circle / cancel | active / blocked states |
6. Motion mapping (module)
| Use | Token | Flutter |
|---|---|---|
| List → detail | m-slow | Hero avatar + page transition |
| Tab content | m-slow | AnimatedSwitcher/TabBarView default |
| Badge morph | m-fast | AnimatedSwitcher |
| List entrance | m-entrance 40 ms stagger | TweenAnimationBuilder |
| Skeleton → content | m-slow | AnimatedOpacity cross-fade |
| Sheet/dialog | m-base | platform defaults |
7. Widget → token additions (module)
| Widget | Token mapping |
|---|---|
StudentListTile | ListTile + surface + outlineVariant divider (desktop) |
StatusBadge | Badge/custom Container role fills + filled icon |
ClassSelectCascade | AppDropdowns, focus ring primary |
ImportReportCard | Card.filled + AppStatTile |
AcademicHistoryTimeline | custom, outlineVariant connectors |
DocumentUploadTile | surfaceContainerLow + LinearProgressIndicator primary |
8. Dark-mode notes (module)
- Roster rows:
surface; dark elevation substitutes for borders (00-shared/02 §10). - Status badges re-read
ColorScheme(no hardcoded green/red). - Timeline connectors
outlineVariant(light) /#49454F(dark token). - Golden tests at light+dark for all module components (00-shared/10 §9).
12 — API Mapping (Students Module)
- 0. Module-wide contract
- 1. List students — Roster
- 2. Get student — Detail
- 3. Create student — Create wizard
- 4. Update student — Edit form
- 5. Delete student (soft) — Delete dialog
- 6. Enroll — Enroll sheet
- 7. Transfer — Transfer form
- 8. Graduate / Archive / Restore — Lifecycle dialogs
- 9. Active enrollments — Detail → Profile/History
- 10. Academic history — Detail → History tab
- 11. Documents
- 12. Bulk import / export
- 13. Parent linking (via Parents module)
- 14. Supporting endpoints (dropdown/identity data)
- Client error mapping table (module)
- Optimistic / undo policy
Exact wire contract for every screen → endpoint. Base
/api/v1. Envelope per 00-shared/07 §2-3. Endpoints fromstudent.controller.ts,parent.controller.ts,bulk.controller.ts,users.controller.ts; business rules from the services cited. All endpoints behindJwtAuthGuard(student.controller.ts:32-35);tenantIdfrom JWT only — never in the body. RBAC enforcement(planned)(OQ-4); client still gates UI onstudent.*.
0. Module-wide contract
| Aspect | Contract |
|---|---|
| Base | https://api.<domain>/api/v1 |
| Headers | Authorization: Bearer <accessToken>; x-request-id (client UUID); Content-Type: application/json (multipart/form-data for uploads) |
| Success | {success:true, message:"OK", data, meta?, timestamp, requestId} — paginated: data array + meta{page,limit,totalItems,totalPages,hasNext,hasPrevious} (pagination-query.dto.ts:32-39) |
| Error | {success:false, message, error:{code, details?}, timestamp, requestId} |
| Codes | 400 VALIDATION_ERROR · 401 UNAUTHENTICATED · 403 PERMISSION_DENIED · 404 RESOURCE_NOT_FOUND · 409 DUPLICATE_RESOURCE · 422 BUSINESS_RULE_VIOLATION · 429 RATE_LIMITED · 5xx INTERNAL_SERVER_ERROR |
| Pagination | page ≥ 1 (default 1), limit 1–100 (default 20), sort (- = desc), q — students find ignores sort/q (OQ-2) |
| Caching | client last-good + stale-while-revalidate (00-shared/06 §3.3); TTLs: roster 5 min (proposed), detail no client cache |
| Offline | reads cached; all writes blocked (no offline queue defined for students) |
| Retry | backoff on 5xx/network; no auto-retry on 429 |
1. List students — Roster
| Endpoint | GET /students (student.controller.ts:41-43) |
| Query | page, limit, sort?, q? (parsed; only page/limit applied — student.service.ts:104-108) |
| Response | 200 data:[StudentDoc…], meta (page/limit/totalItems/totalPages/hasNext/hasPrevious) |
| StudentDoc shape | _id, tenantId, userId, admissionNumber, rollNumber?, academicYearId, campusId?, gradeId, sectionId, classId, houseId?, admissionDate?, admissionType?, status, transportRequired, hostelRequired, medicalNotes?, metadata?, createdAt, updatedAt, version, isDeleted:false (student.schema.ts:15-64 + base.schema.ts) |
| Errors | 401; 429; 5xx |
| Client | infinite scroll on meta.hasNext; client-side filters/sort (server ignores q/sort — OQ-2) |
2. Get student — Detail
GET /students/:id (student.controller.ts:44-46) → 200 data:StudentDoc;
404 RESOURCE_NOT_FOUND "Student not found." (student.service.ts:95-99).
3. Create student — Create wizard
| Endpoint | POST /students (student.controller.ts:38-40) |
| Body | CreateStudentDto — see 08 §1 (userId*, admissionNumber*, academicYearId*, gradeId*, sectionId*, classId*; rest optional) |
| Response | 200 data:StudentDoc with status:"active" (student.service.ts:65) + auto-enrolled ACTIVE class_enrollments row (student.service.ts:71-78) |
| Errors | 400 validation; 409 DUPLICATE_RESOURCE "Admission number "x" already exists." (student.service.ts:56-62); 429; 5xx |
| Events | StudentCreated {studentId, admissionNumber, classId} → in-app/student-enrolled (event-queue-map.ts:28); audit via event bus |
4. Update student — Edit form
PATCH /students/:id (student.controller.ts:56-58) — body UpdateStudentDto
(all optional; status is unvalidated string — UI sends enum values only).
200 data:StudentDoc; 404; 409 n/a; emits StudentUpdated {changes:[field…]}
→ audit-write/log-student-updated (event-queue-map.ts:29). Note version
increments on every write (base.repository.ts:57-66).
5. Delete student (soft) — Delete dialog
DELETE /students/:id (student.controller.ts:59-61) → 200 (data empty);
404; emits StudentDeleted → audit-write/log-student-deleted
(event-queue-map.ts:30). Soft delete: isDeleted:true, deletedAt, deletedBy
(base.repository.ts:68-74) — excluded from all queries. No cascade to
documents/enrollments/links (OQ-7). No restore endpoint for soft-deleted.
6. Enroll — Enroll sheet
| Endpoint | POST /students/:id/enroll (student.controller.ts:50-55) |
| Body | EnrollStudentDto {classId*, academicYearId*, rollNumber?} |
| Behaviour | deactivates all ACTIVE enrollments (status:"transferred", leftAt:now) then creates new ACTIVE (student.service.ts:125-140) |
| Response | 200 data:ClassEnrollmentDoc {studentId, classId, academicYearId, rollNumber?, joinedAt, leftAt?, status:"active"} (class-enrollment.schema.ts:14-39) |
| Errors | 404 (student); 400; 429; 5xx |
7. Transfer — Transfer form
| Endpoint | POST /students/:id/transfer (student.controller.ts:62-65) |
| Body | TransferStudentDto {classId*, academicYearId*, gradeId?, sectionId?, rollNumber?} |
| Behaviour | guard status === "active" else 409 Cannot transfer a student with status "x". (student.service.ts:175-179) → enroll (same deactivation) → $set {classId, academicYearId, gradeId?, sectionId?} (student.service.ts:180-192) |
| Response | 200 data:StudentDoc (updated) |
| Errors | 404; 409 (wrong status); 400; 429; 5xx |
| Events | StudentUpdated {changes:["transfer"]} → audit |
8. Graduate / Archive / Restore — Lifecycle dialogs
| Action | Endpoint | Guard | Success → data | Source |
|---|---|---|---|---|
| Graduate | POST /students/:id/graduate | 409 "Student is already graduated." | status:"graduated" | student.controller.ts:66-69, student.service.ts:205-223 |
| Archive | POST /students/:id/archive | 409 "Student is already archived." | status:"archived" | student.controller.ts:70-73 |
| Restore | POST /students/:id/restore | 409 "Student is already active." | status:"active" | student.controller.ts:74-77 |
All: 404; 200 data:StudentDoc; emit StudentUpdated {changes:["graduate"|"archive"|"restore"]}.
9. Active enrollments — Detail → Profile/History
GET /students/:id/enrollments (student.controller.ts:47-49) → 200
data:[ClassEnrollmentDoc…] active only (findActiveByStudent —
class-enrollment.repository.ts:20-24); 404.
10. Academic history — Detail → History tab
GET /students/:id/academic-history (student.controller.ts:91-94) → 200
data:[ClassEnrollmentDoc…] all enrollments sorted joinedAt desc
(student.service.ts:281-287); 404.
11. Documents
| List | GET /students/:id/documents (student.controller.ts:78-81) → 200 data:[StudentDocumentDoc…] sorted createdAt desc (student.service.ts:273-279) — shape {studentId, fileName, mimeType, size, fileId, category?, uploadedBy?, createdAt} (student-document.schema.ts:8-29) |
| Upload | POST /students/:id/documents (student.controller.ts:82-90) — multipart/form-data, file field file, text field category?; 200 data:StudentDocumentDoc; no server size/type validation (OQ-9) |
| Download | none — only fileId metadata; local provider path /api/v1/files/<tenantId>/<uuid>--<name> (local-storage.provider.ts:47-51) — preview/download (planned) |
12. Bulk import / export
| Import | POST /bulk/import/:entity where entity="students" (bulk.controller.ts:35-48; only adapter — bulk-import.service.ts:17-20) — multipart field file; missing → 400 "CSV file is required (multipart field "file")."; 200 data:ImportReport {entity, totalRows, imported, failed, errors:[{rowNumber, errors[]}]} (import-adapter.interface.ts:14-25) |
| Parse errors | 400 "Malformed CSV: could not parse file." / "CSV must include a header row and data." (bulk-import.service.ts:31-35) |
| Unknown entity | 404 No import adapter for entity "x". |
| Export | GET /bulk/export/:entity (bulk.controller.ts:50-60) → 200 text/csv, Content-Disposition: attachment; filename="students.csv" — columns admissionNumber, rollNumber, status, admissionDate sorted by admissionNumber (students-import.adapter.ts:87-98) |
Per-row errors (exact strings) in 08 §6; row numbers are index+2 (header =
row 1) (bulk-import.service.ts:45-46). Synchronous; no job/progress endpoint
(OQ-12).
13. Parent linking (via Parents module)
| List links | GET /parents/link/student/:studentId (parent.controller.ts:53-57) → 200 data:[StudentParentLinkDoc…] {studentId, parentId, relationship, isPrimaryGuardian, financialResponsibility, pickupAllowed, emergencyPriority, metadata?} (student-parent-link.schema.ts:17-41) |
| Create parent | POST /parents (parent.controller.ts:29-31) — CreateParentDto; 409 "Parent profile already exists for this user." (parent.service.ts:30-34) |
| Link | POST /parents/link/:studentId (parent.controller.ts:47-52) — LinkParentDto (relationship* required); 404 unknown student (student-parent-link.service.ts:27); parent existence not verified (OQ-11) |
| Unlink | DELETE /parents/link/:linkId (parent.controller.ts:58-60) — soft delete; 404 "Link not found." (student-parent-link.service.ts:38-41) |
| Parent's children | GET /parents/:id/students (parent.controller.ts:38-40) → link docs for that parent |
| Update link | none — edit = unlink + re-link |
14. Supporting endpoints (dropdown/identity data)
| Purpose | Endpoint | Source |
|---|---|---|
| Academic years | GET /academic-years (page/limit) | academics/controllers/academic-year.controller.ts:27-29 |
| Grades | GET /grades | grade.controller.ts:27-29 |
| Sections by grade | GET /sections/by-grade/:gradeId | section.controller.ts:30-33 |
| Classes | GET /classes ; GET /classes/by-year/:academicYearId | class.controller.ts:27-33 |
| Create user (identity step) | POST /users | users.controller.ts:36-40 |
| Search users (reuse) | GET /users?q= (server-side q handling per users module) | users.controller.ts:42-46 |
| Avatar | POST /users/:id/avatar (multipart file) | users.controller.ts:95-102 |
Client error mapping table (module)
| Screen | Code | UI |
|---|---|---|
| any | 401 | silent refresh → sessionExpired (00-shared/06 §3.6) |
| any action | 403 | hide action / 403 screen (server RBAC (planned)) |
| create | 409 admission | inline field "already exists" |
| create (users) | 409 email | step-1 inline + offer reuse |
| transfer | 409 status | read-only status banner |
| graduate/archive/restore | 409 same state | snackbar + refresh |
| documents upload | 400/5xx | tile error + retry |
| import | 400 parse | wizard error banner with message |
| import | 404 entity | generic (should not happen) |
| list/detail | 404 | empty-state "not found" |
| any | 429 | countdown, CTA disabled |
| any | 5xx | generic + requestId (00-shared/06 §5) |
Optimistic / undo policy
- Never optimistic on: create, enroll, transfer, graduate, archive, restore, delete, link/unlink, document upload, import (all server-truth, side effects).
- Row removal (unlink, delete) reflects only after server 200.
- Undo: not offered (no restore endpoint for soft-delete; archive has explicit Restore action instead).
13 — State Management (Students Module)
- 1. Module data model (client)
- 2. Cubit map
- 3. Roster state machine (pagination + filters)
- 4. Detail tabs lazy-load
- 5. Create wizard state
- 6. Import wizard state (progress contract)
- 7. Documents upload state
- 8. Events & actions map (UI → Cubit → Repository)
- 9. Realtime & cross-screen
- 10. Error states per action
- 11. Testing hooks (
00-shared/06 §6)
Per-screen Cubits (Flutter/bloc; proposal — 00-shared/06) + repositories for the endpoints in 12_API_Mapping.md. Conventions: one Cubit per screen,
LoadStatemachine (00-shared/06 §3.1),PaginatedListMixinfor the roster (00-shared/06 §3.2).
1. Module data model (client)
class Student {
String id; String userId;
String admissionNumber; String? rollNumber;
String academicYearId; String? campusId;
String gradeId; String sectionId; String classId; String? houseId;
DateTime? admissionDate; String? admissionType; StudentStatus status;
bool transportRequired; bool hostelRequired; String? medicalNotes;
Map<String, dynamic>? metadata;
// hydrated (joined):
UserRef? user; // name/email/phone/avatarUrl from GET /users/:id
ClassRef? classRef; // label from academics refs (dropdown sources)
}
enum StudentStatus { active, inactive, graduated, transferred, archived }
class Enrollment {
String id; String studentId; String classId; String academicYearId;
String? rollNumber; DateTime joinedAt; DateTime? leftAt; EnrollmentStatus status;
}
class StudentDocument {
String id; String studentId; String fileName; String mimeType;
int size; String fileId; String? category; String? uploadedBy; DateTime createdAt;
}
class ParentLink {
String id; String studentId; String parentId; RelationshipType relationship;
bool isPrimaryGuardian; bool financialResponsibility; bool pickupAllowed;
int emergencyPriority;
}
class ImportReport {
String entity; int totalRows; int imported; int failed;
List<ImportRowError> errors; // {rowNumber, errors[]}
}
Enums map 1:1 to server (student.schema.ts:7-13, class-enrollment.schema.ts:7-11,
student-parent-link.schema.ts:7-14). user/classRef hydration is
client-side join (GET /users/:id + academics refs) — no server join exists.
2. Cubit map
| Screen | Cubit | Events → State |
|---|---|---|
| Roster | StudentsListCubit | Load(page1), LoadMore, Refresh, ChangeFilters(status/class/year), Search(q), Retry → {load: LoadState, items[], meta, filters, query, searchMode} |
| Detail | StudentDetailCubit | Load(id), Refresh, `RunAction(Action.graduate |
| Create wizard | CreateStudentCubit | `IdentityStep(createUser |
| Enroll sheet | EnrollCubit | Submit(classId, yearId, roll) → {idle, submitting, success, error} |
| Transfer | TransferCubit | Load(current), Submit(form) → {idle, loading, submitting, blocked(status), success, error} |
| Documents | DocumentsCubit | Load, Upload(file, category), RetryUpload → {load, docs[], uploading{tile}, error} |
| History | HistoryCubit | Load → {load, enrollments[]} (academic-history + active enrollments merged) |
| Parents tab | ParentLinksCubit | Load, CreateParent(form), Link(dto), Unlink(id) → {load, links[], sheetState} |
| Import wizard | ImportCubit | DownloadTemplate, PickFile(parsed), Upload, Retry → {step, file, report?, submitting} |
3. Roster state machine (pagination + filters)
stateDiagram-v2
[*] --> initial
initial --> loading : Load
loading --> success(data,meta) : 200
loading --> error(code) : 4xx/5xx
success --> loadingMore : LoadMore (hasNext)
loadingMore --> success (append) | error(tail banner)
success --> loading : Refresh / ChangeFilters / Search (reset page)
success --> empty : totalItems == 0 | filters no match
error --> loading : Retry
PaginatedListMixincontract:page,limit(20),hasNext,items,loadFirst/loadMore/pullToRefresh(00-shared/06 §3.2).- Server
q/sortignored (OQ-2) → filters/search run client-side over accumulated pages; when filter set,LoadMorekeeps paging server-side and applies filter locally.searchModeflag toggles helper text (honesty rule09 §1). - Cache: last-good list per key
sl:{tenant}:students:list:{page}(Hive/prefs, TTL 5 min(proposed)) — served instantly offline, refreshed in background.
4. Detail tabs lazy-load
flowchart TD
A[StudentDetailCubit Load] --> B[profile: GET /students/:id]
B --> C{tab activated}
C -->|Documents| D[GET /students/:id/documents]
C -->|History| E[GET /students/:id/academic-history<br/>+ GET /students/:id/enrollments]
C -->|Attendance/Fees/Results| F[other-module repos — forward-looking]
D & E --> G[kept alive; Refresh re-fetches]
- Each tab owns its LoadState; failures isolated (tab shows compact
AppErrorState, screen stays). - After
RunActionsuccess →Refresh()profile + badge; history refresh on transfer/enroll; documents refresh after upload.
5. Create wizard state
class CreateStudentState {
int step; // 0..3 (identity, academics, extras, review)
CreateUserDraft user; // firstName, middleName?, lastName, email, phone?
bool reuseExistingUser; String? userId;
ClassSelection selection; // academicYearId, gradeId, sectionId, classId
ExtrasDraft extras; // admissionDate?, admissionType?, campusId?, houseId?,
// transportRequired, hostelRequired, medicalNotes?, rollNumber?
bool submitting; String? createdId; WizardError? error; // field-targeted
}
Submitsequence (exact): if new user →usersRepo.create→ on 409 switch toreuseExistingUserprompt; thenstudentsRepo.create→createdId→ navigate/students/:id.- No draft persistence across app kills (YAGNI —
09 §2). - 400 field errors mapped via
details[].field; 409 admission → field error on admission number.
6. Import wizard state (progress contract)
class ImportState {
int step; // template | file | uploading | report
ParsedCsv? preview; // headers + first rows + column warnings
bool submitting;
ImportReport? report;
ImportPhase phase; // idle | uploading(simulated) | done
}
- Server import is synchronous (loop in request,
bulk-import.service.ts:45-63) — no job id, no progress API (OQ-12). UI: indeterminate progress + elapsed rows readout (client estimatetotalRows), disable back during upload. - Report render from
ImportReport;rowNumber-grouped errors; "Done" resets list cache (invalidate roster).
7. Documents upload state
UploadingDoc {fileName, size, progress(0..1), phase: queued|uploading|done|error}
in DocumentsCubit; multipart via dio FormData; retry re-posts buffered bytes;
no chunked resume (00-shared/12 B7).
8. Events & actions map (UI → Cubit → Repository)
| UI event | Cubit method | Repository call |
|---|---|---|
| Roster open / refresh | load() / refresh() | studentsRepo.list(page, limit) |
| Infinite scroll | loadMore() | studentsRepo.list(page+1, limit) |
| Search / filters | search(q) / setFilters(f) | client-side (plus server page fetch) |
| Row → View | select(id) | detail load(id) |
| Create submit | submit() | usersRepo.create → studentsRepo.create |
| Enroll | submit(form) | studentsRepo.enroll(id, dto) |
| Transfer | submit(form) | studentsRepo.transfer(id, dto) |
| Graduate/Archive/Restore | runAction(a) | `studentsRepo.graduate |
| Delete | runAction(delete) | studentsRepo.delete(id) |
| Documents load/upload | load() / upload(file, cat) | documentsRepo.list(id) / documentsRepo.upload(id, file, cat) |
| History load | load() | studentsRepo.academicHistory(id) + enrollments(id) |
| Parents load | load() | parentsRepo.linksByStudent(id) |
| Create parent | createParent(draft) | usersRepo.create → parentsRepo.create |
| Link | link(dto) | parentsRepo.link(studentId, dto) |
| Unlink | unlink(linkId) | parentsRepo.unlink(linkId) |
| Import template | downloadTemplate() | bulkRepo.exportCsv('students') → file save/share |
| Import upload | upload(file) | bulkRepo.importCsv('students', file) |
9. Realtime & cross-screen
- No student WS topics (
00-shared/07 §8); roster freshness via refresh + focus re-fetch;roster.changed(forward-looking). StudentCreatedin-app notification tap → deep link/students/:id(proposed).- Academic-year switcher (
00-shared/06 §4selectors) affects class cascade defaults in create/enroll/transfer forms.
10. Error states per action
| Action | Error | State → |
|---|---|---|
| create | 409 admission / email | field error; reuse-user prompt |
| create | 400 | field map |
| transfer | 409 status | blocked(status) → read-only banner |
| lifecycle | 409 same-state | snackbar + refresh |
| unlink/delete | 404 | treat as removed |
| upload | network loss | tile error + retry |
| import | 400 parse | wizard banner |
| any | 429 | countdown |
| any | 5xx | generic + requestId |
11. Testing hooks (00-shared/06 §6)
- Pure-Dart cubits with mocked repositories; roster state-machine matrix (initial→loading→success→empty→error→loadingMore); wizard sequence test (create user 409 → reuse path); import report rendering (mixed success).
- Widget tests per screen: 3 states (loading/error/success+empty) per
00-shared/10 §9.
14 — QA Checklist (Students Module)
- 1. Data integrity & business rules
- 2. Enrollment conflict scenarios
- 3. Document limits
- 4. Bulk import — 1000-row file (target scenario per PLAN 15.3)
- 5. Multi-tenancy & security (baseline §10)
- 6. Performance budgets (baseline §1 applied)
- 7. Offline matrix (baseline §2 applied)
- 8. A11y spot checks (baseline §9 + 09_Accessibility_Baseline.md)
- 9. Localization & format
- 10. Regression pack (server-verified behaviours worth automating)
- 11. Analytics events (proposed) to smoke-test
Module-specific quality gates on top of 00-shared/10 (baseline applies to every item here). Each checklist row is traceable to a server behaviour cited inline.
1. Data integrity & business rules
| # | Check | Expected | Server source |
|---|---|---|---|
| 1.1 | Duplicate admission number | Create → 409 "Admission number "x" already exists."; import row → per-row error | student.service.ts:56-62; students-import.adapter.ts:49-54 |
| 1.2 | Duplicate admission via race (two clients) | unique index {tenantId, admissionNumber} prevents both; one wins, other 409 | student.schema.ts:68 |
| 1.3 | Create auto-enroll | response student + one ACTIVE enrollment with joinedAt ≈ now | student.service.ts:71-78 |
| 1.4 | Enroll closes previous | old enrollment status:"transferred" + leftAt set; new ACTIVE; history append-only | student.service.ts:125-139; COLLECTIONS.md:1674-1677 |
| 1.5 | Transfer guard | non-active status → 409 with exact status in message; no enrollment change happens | student.service.ts:175-179 |
| 1.6 | Graduate idempotency | second graduate → 409 "already graduated"; status stays graduated | student.service.ts:207-209 |
| 1.7 | Archive/restore idempotency | archive twice → 409; restore twice → 409 "already active" | student.service.ts:228-230 |
| 1.8 | Soft delete isolation | after DELETE, student absent from list/detail/documents/history; isDeleted:true | base.repository.ts:20-30,68-74 |
| 1.9 | No cascade on delete | documents/links/enrollments remain queryable by id | student.service.ts:157-168 (no cascade) |
| 1.10 | Transfer writes profile | classId/academicYearId updated; gradeId/sectionId only when provided | student.service.ts:185-192 |
2. Enrollment conflict scenarios
| # | Scenario | Expected |
|---|---|---|
| 2.1 | Enroll same class twice in a row | second enroll deactivates first (transferred), creates new ACTIVE — allowed by server; UI warns (no server 409) — flagged OQ-13 |
| 2.2 | Enroll during active transfer UI | server processes sequentially; last write wins (version increments) |
| 2.3 | Concurrent PATCH + transfer | findOneAndUpdate + $inc version — no lost-update corruption (base.repository.ts:57-66); UI refreshes after both |
3. Document limits
| # | Check | Expected | Source |
|---|---|---|---|
| 3.1 | No-file upload | request without file → 400 (multer) | student.controller.ts:82-90 |
| 3.2 | Oversized file | no server limit today — must be enforced client + proxy (proposed) 10 MB | OQ-9; student.service.ts:250-271 |
| 3.3 | Unsupported mime | no server allowlist — client (proposed) allowlist pdf/jpg/png/webp | OQ-9 |
| 3.4 | Empty category | allowed (optional string) — UI shows uncategorized chip | upload-student-document.dto.ts:4-9 |
| 3.5 | Uploaded metadata correctness | fileName/mimeType/size match actual file; uploadedBy = actor | student.service.ts:262-270 |
| 3.6 | List order | newest first (createdAt desc) | student.service.ts:273-279 |
4. Bulk import — 1000-row file (target scenario per PLAN 15.3)
| # | Check | Expected |
|---|---|---|
| 4.1 | Valid 1000-row CSV | report {totalRows:1000, imported:1000, failed:0, errors:[]}; 1000 students + users created; no timeout → request budget: set client timeout ≥ 120 s for import (00-shared/11 §5) |
| 4.2 | Header row only (0 data rows) | 400 "CSV must include a header row and data." |
| 4.3 | Malformed CSV (unbalanced quotes) | 400 "Malformed CSV: could not parse file." |
| 4.4 | Missing required column | per-row error "Missing required column "section"."; row number = index+2 |
| 4.5 | Bad email format | "Invalid email format." |
| 4.6 | Duplicate admission number in file (row 5 duplicates existing) | row 5 failed "already exists"; other rows import |
| 4.7 | Duplicate email in file | first row creates user; second row fails "already registered" (validate pre-check) — or falls back to existing user inside create if validate missed it (race) |
| 4.8 | Unknown academic year/grade/section | per-row "Academic year "x" not found." / "Grade … not found." / "Section … not found." |
| 4.9 | Grade+section with no class | "No class found for grade "g" section "s"." |
| 4.10 | Grade match by code | grade matched on name or code |
| 4.11 | Row-level failure isolation | one bad row never aborts the batch (try/catch per row) |
| 4.12 | Import from non-authorized user | currently allowed (JWT-only) — verify when RBAC lands (OQ-4) |
5. Multi-tenancy & security (baseline §10)
| # | Check |
|---|---|
| 5.1 | Cross-tenant :id on any endpoint → 404/403, no data leak (base.repository.ts:20-30) |
| 5.2 | tenantId never in request bodies from client |
| 5.3 | Duplicate email/user cross-tenant never resolves (users repo is tenant-scoped too) |
| 5.4 | uploadedBy/createdBy set from JWT, never from body |
| 5.5 | Soft-deleted students not returned by any scoped query |
| 5.6 | Parent link of tenant A student with tenant B parent → structurally impossible (both scoped) |
6. Performance budgets (baseline §1 applied)
| Metric | Budget |
|---|---|
| Roster first frame (cached) | < 300 ms |
| Roster network content | skeleton ≤ 200 ms, content ≤ 2 s (page 1, limit 20) |
| 1000-row import | completes; client timeout 120 s+; no UI freeze (parse on isolate) |
| Detail load | profile immediate; tabs lazy (documents/history only on activation) |
| Scroll | 0 dropped frames; ListView.builder + const tiles |
7. Offline matrix (baseline §2 applied)
| Scenario | Expected |
|---|---|
| Offline → roster | cached rows + AppOfflineBanner; pull-refresh → Retry error state |
| Offline → create/transfer/upload/import/link | blocked with guidance (no offline queue) |
| Network loss mid-upload | DocumentUploadTile error + Retry; no silent corruption |
| Network loss mid-import | request fails → wizard error; re-import is idempotent per-row (admission/email dedup) |
8. A11y spot checks (baseline §9 + 09_Accessibility_Baseline.md)
- Status badges: icon + text, never color-only (00-shared/09 §9).
- Search results count in live region; "item X of Y" list position.
- Wizard: focus first invalid field on Next; step index announced.
- Import report: live-region summary "997 of 1000 imported".
- Timeline: readable list semantics; badges announced.
- Text scale 2×: no clipping in header card, tiles, report cards.
- Dialogs trap focus; Esc/back closes; focus returns to trigger.
9. Localization & format
- Dates via
Intl(admissionDate, joinedAt/leftAt, createdAt); money none. ADMnumbers inmono, not localized.- RTL readiness (relationship chips, timelines mirror).
10. Regression pack (server-verified behaviours worth automating)
- create → auto-enroll → history shows 1 ACTIVE.
- enroll → old enrollment transferred + leftAt.
- transfer on archived student → 409.
- graduate → archive → restore sequence statuses.
- delete → list excludes; detail 404.
- import mixed file → report numbers + error strings.
- link parent → student missing → 404; parent missing → no error today (OQ-11 — decide and test when resolved).
- documents: upload → list order newest-first.
- pagination: page 2 offset math (
skip = (page-1)*limit,student.service.ts:104). - envelope: every response
{success,message,data,meta?,timestamp,requestId}; every error carriescode.
11. Analytics events (proposed) to smoke-test
students.list.{view,search,filter,open}, students.create.{start,step,success,failure},
students.transfer.{submit,success,failure}, students.import.{template,upload,report},
students.document.{upload,open}, students.lifecycle.{graduate,archive,restore,delete} — per 00-shared/10 §8.
15 — Flutter Implementation Guide (Students Module)
- 1. Feature folder structure
- 2. Routing (go_router)
- 3. DI (get_it)
- 4. Repository contracts (dio)
- 5. Key implementation notes
- 6. Testing strategy (module)
- 7. Dependencies to add (module-scoped, verify licenses)
- 8. Perf notes
Build guidance for the Students feature slice on top of 00-shared/11 (project layout, DI, dio, router, theme, testing). Forward-looking spec — no client repo exists yet.
1. Feature folder structure
lib/features/students/
├── data/
│ ├── dto/student_dto.dart # envelope-payload mappers
│ ├── dto/class_enrollment_dto.dart
│ ├── dto/student_document_dto.dart
│ ├── dto/parent_link_dto.dart
│ ├── dto/import_report_dto.dart
│ ├── models/student.dart # domain models (13 §1)
│ ├── models/enrollment.dart
│ ├── models/student_document.dart
│ ├── models/parent_link.dart
│ ├── models/import_report.dart
│ └── repositories/
│ ├── students_repository.dart # list/detail/create/update/delete/enroll/transfer/lifecycle
│ ├── documents_repository.dart
│ ├── parent_links_repository.dart
│ └── bulk_repository.dart # import/export csv
├── domain/
│ └── (skip — 1:1 repo calls; add use cases when >1 repo interaction, e.g. create wizard: users+students)
└── presentation/
├── cubit/students_list_cubit.dart
├── cubit/student_detail_cubit.dart
├── cubit/create_student_cubit.dart
├── cubit/enroll_cubit.dart
├── cubit/transfer_cubit.dart
├── cubit/documents_cubit.dart
├── cubit/history_cubit.dart
├── cubit/parent_links_cubit.dart
├── cubit/import_cubit.dart
├── pages/students_list_page.dart
├── pages/student_detail_page.dart
├── pages/create_student_page.dart
├── pages/import_students_page.dart
├── pages/transfer_page.dart
└── widgets/ # StudentListTile, StatusBadge, StudentHeader,
# AcademicHistoryTimeline, ImportReportCard, RelationshipChip,
# GuardianLinkCard, ClassSelectCascade, DocumentUploadTile,
# RosterFilterBar (07 Component Library)
2. Routing (go_router)
GoRoute(
path: '/students',
parentNavigatorKey: shellNavigatorKey, // StatefulShellBranch
builder: (_, __) => const StudentsListPage(),
routes: [
GoRoute(path: 'add', builder: (_, __) => const CreateStudentPage()),
GoRoute(path: 'import', builder: (_, __) => const ImportStudentsPage()),
GoRoute(
path: ':id',
builder: (_, s) => StudentDetailPage(studentId: s.pathParameters['id']!),
routes: [
GoRoute(path: 'edit', ...),
GoRoute(path: 'transfer', ...),
],
),
],
)
Guards: permissionGuard('student.read') on /students; student.create on
add; student.update on edit/transfer; file.upload on documents actions
(00-shared/11 §6). Deep link studylyon://students/:id → detail.
3. DI (get_it)
getIt.registerLazySingleton<StudentsRepository>(() => StudentsRepository(getIt<AppDio>()));
getIt.registerLazySingleton<DocumentsRepository>(...);
getIt.registerLazySingleton<ParentLinksRepository>(...);
getIt.registerLazySingleton<BulkRepository>(...);
getIt.registerFactory<StudentsListCubit>(() => StudentsListCubit(getIt<StudentsRepository>()));
// … one factory per cubit; no singletons holding screen state (00-shared/11 §2)
Create-wizard dependency on UsersRepository via getIt<UsersRepository>()
(module boundary: repository call, not service — preserves module boundaries in
the client as server events do server-side).
4. Repository contracts (dio)
class StudentsRepository {
Future<Paginated<Student>> list({int page = 1, int limit = 20});
Future<Student> byId(String id);
Future<Student> create(CreateStudentDto dto); // POST /students
Future<Student> update(String id, UpdateStudentDto dto);
Future<void> delete(String id); // soft
Future<Enrollment> enroll(String id, EnrollStudentDto dto);
Future<Student> transfer(String id, TransferStudentDto dto);
Future<Student> graduate(String id);
Future<Student> archive(String id);
Future<Student> restore(String id);
Future<List<Enrollment>> academicHistory(String id);
Future<List<Enrollment>> activeEnrollments(String id);
}
class DocumentsRepository {
Future<List<StudentDocument>> list(String studentId);
Future<StudentDocument> upload(String studentId, Uint8List bytes,
String fileName, String mimeType, String? category); // FormData field "file"
}
class ParentLinksRepository {
Future<List<ParentLink>> linksByStudent(String studentId);
Future<ParentLink> link(String studentId, LinkParentDto dto);
Future<void> unlink(String linkId);
}
class BulkRepository {
Future<ImportReport> importCsv(String entity, Uint8List bytes, String fileName);
Future<String> exportCsv(String entity); // raw csv text
}
- Envelope decoding + typed
ApiException(code,status,message,details)via shared interceptor (00-shared/11 §5). - Timeouts: default 15 s; import 120 s; upload 120 s.
- Multipart:
FormData.fromMap({'file': MultipartFile.fromBytes(...)})— field name exactlyfile(student.controller.ts:83).
5. Key implementation notes
| Topic | Guidance |
|---|---|
| Status enum | one Dart enum mirroring server; parse with fallback → unknown status renders neutral badge (never crash on new values) |
| Client-side filters | roster filter chips operate on accumulated items; keep serverPage separate from filteredList (13 §3) |
| Search honesty | searchMode helper text per 09 §1 (server q ignored — OQ-2) |
| Import CSV parse | csv dart package on an isolate for > 500 rows (00-shared/11 §13); header/column check before upload (mirror students-import.adapter.ts rules client-side for early feedback, server report is authoritative) |
| Import progress | indeterminate; no fake percentage (09 §5, OQ-12) |
| Document preview | gate on file-serving endpoint existence (OQ-9); until then show metadata only |
| Identity hydration | detail joins GET /users/:id for name/avatar; fallback initials avatar |
| Class cascade | cache academics reference lists 24 h (00-shared/06 §3.3); ClassSelectCascade derives class by grade+section+year client-side where by-year list is coarse |
| Hero | avatar hero tag student-{id} list→detail (00-shared/08 §4) |
| Analytics | AnalyticsService events per 14 §11 (proposed) |
6. Testing strategy (module)
| Layer | Tests |
|---|---|
| Unit — cubits | StudentsListCubitTest (state machine incl. filters/loadMore/retry), CreateStudentCubitTest (user-409 → reuse path), ImportCubitTest (report render), TransferCubitTest (blocked status) |
| Unit — mappers | DTO→model incl. unknown status fallback; envelope error mapping |
| Widget | each screen 3 states (loading/error/success+empty); roster filter chips; import report card partial success; document upload tile states |
| Golden | module components light/dark × 3 sizes (00-shared/10 §9) |
| Integration | journey: create user → create student → detail shows ACTIVE → transfer → history shows transferred; import 50-row CSV happy+failed mix |
| E2E (device cloud) | P0: roster → create → documents upload → link parent (00-shared/10 §9) |
7. Dependencies to add (module-scoped, verify licenses)
csv(parse client previews) — or hand-roll minimal splitter for header check (YAGNI: start withcsv, it handles quotes correctly).- No chart/markdown/QR deps needed in this module.
8. Perf notes
ListView.builder+consttiles;RepaintBoundaryon timeline; avatar images cached/resized (cached_network_image).- Tab keep-alive with lazy first-build (documents/history fetch on first activation only).
- Import parse off main isolate for ≥ 500 rows; UI never blocks.
01 — Product Overview (Teachers Module)
- 1. Purpose
- 2. Module boundary: Teachers vs Staff
- 3. Teacher lifecycle (end-to-end)
- 4. Subject & class assignment — two mechanisms (important)
- 5. Permissions reality (read carefully)
- 6. Events & side-effects
- 7. Dependencies
- 8. Business goals
- 9. Success metrics
- 10. Edge cases (derived)
- 11. Assumptions (module)
- 12. Open questions (module-level)
StudyLyon — multi-tenant ERP / School Management API. This package designs the Teachers module client (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, events, and business rules are derived directly from
src/modules/teachers/**,src/modules/academics/**(subject assignments),src/modules/staff/**,src/modules/rbac/**, andsrc/infrastructure/**. No feature is invented; gaps are flagged in Assumptions & Open Questions and marked(planned)/(proposed).
1. Purpose
Teachers is the teaching-profile domain. It answers "who teaches here" for an
institution: the employment record of every instructor (linked to a User identity),
their department/designation, employment status, qualifications, the subjects they
teach and the classes they are class-teacher for, plus — via the
subject_assignments collection owned by the Academics module — the per-academic-year
matrix of which teacher teaches which subject in which class.
| Responsibility | Source |
|---|---|
| Teacher profile CRUD (create/read/update/deactivate) | teacher.controller.ts:24-38 |
| Duplicate guards (userId, employeeNumber) on create | teacher.service.ts:27-38 |
Soft-delete (deactivate) with isDeleted flag | base.repository.ts:68-74, teacher.service.ts:95-106 |
Employment status enum (active/inactive/on_leave/terminated) | teacher.schema.ts:7-12 |
| Subject ↔ class ↔ academic-year teaching matrix | subject-assignment.controller.ts:21-38, subject-assignment.schema.ts:9-19 |
| Denormalized subject/class-teacher references on profile | teacher.schema.ts:44-48 |
| Domain events → in-app notification + audit + search index | teacher.service.ts:40-50,84-91,98-105; event-queue-map.ts:31-33; search-indexer.service.ts:10,17,24 |
| Teacher count KPI for dashboard | dashboard.service.ts:32,52 |
2. Module boundary: Teachers vs Staff
The backend splits "people" into two parallel profile documents that share the same shape for employment basics (userId, employeeNumber, departmentId, designationId, joiningDate) but diverge on domain specifics:
| Aspect | Teacher (teachers) | Staff (staff) |
|---|---|---|
| Blueprint identity | "Teaching profile." (COLLECTIONS.md:1418-1422) | "Non-teaching employees." (blueprint 04-Modules/Staff.md:3) |
| Employment fields | employmentStatus, qualification, experienceYears (teacher.schema.ts:28-42) | employmentType, salaryGrade (staff.schema.ts:35-47) |
| Academic linkage | subjects[], classTeacherFor[] (teacher.schema.ts:44-48) | none |
| Unique constraints | tenantId+userId, tenantId+employeeNumber (teacher.schema.ts:56-57) | tenantId+userId, tenantId+employeeNumber (staff.schema.ts:57-58) |
| CRUD surface | teacher.controller.ts:24-38 | staff.controller.ts:24-38 |
| Events | TeacherCreated/Updated/Deleted | StaffCreated/Updated/Deleted |
| Reference catalogs | departments/designations owned by Staff module (Staff.md:59) | departments/designations owned here |
| Status field name | employmentStatus (enum EmploymentStatus) | status (enum StaffStatus) — same 4 values |
A single users record may own a teacher profile and a staff profile (e.g. a teacher
who also works the front office): RELATIONSHIPS.md:32-33 ("A single users record may
own multiple profiles"). Both can even coexist with the same userId because they are
separate collections with separate uniqueness scopes. The client must treat them as
two different records — the Teachers screen never renders Staff records, and vice
versa.
3. Teacher lifecycle (end-to-end)
User account created (Users module)
→ Teacher profile created POST /teachers [TeacherCreated → in-app]
→ Subjects + classes assigned (two mechanisms, §4)
→ Day-to-day teaching (timetable, attendance, homework — other modules)
→ Profile edited PATCH /teachers/:id [TeacherUpdated → audit]
→ Deactivated DELETE /teachers/:id [TeacherDeleted → audit]
(soft-delete: isDeleted=true, queries exclude; PLAN.md:146)
Deactivation is permanent from the client's perspective — there is no
re-activation endpoint. DELETE sets isDeleted: true + deletedAt + deletedBy
(base.repository.ts:68-74); every subsequent query excludes the record
(base.repository.ts:20-30).
4. Subject & class assignment — two mechanisms (important)
The backend has two parallel, non-synchronized ways to link a teacher to subjects/classes:
- Profile arrays —
teacher.subjects[](refSubject) andteacher.classTeacherFor[](refClass), set viaPOST /teachersorPATCH /teachers/:id(create-teacher.dto.ts:44-52,teacher.schema.ts:44-48). These are denormalized profile data — there is no server logic that reads them for scheduling. subject_assignmentscollection — one document per (teacher, subject, class, academicYear) triple:subject-assignment.schema.ts:9-19, managed byPOST /subject-assignments,GET /subject-assignments/by-class/:classId,GET /subject-assignments/by-teacher/:teacherId,DELETE /subject-assignments/:id(subject-assignment.controller.ts:21-38). This is the authoritative teaching matrix per academic year (RELATIONSHIPS.md:82-92).
Additionally classes.classTeacherId exists (class.schema.ts:28) as a third
class-teacher reference. The three stores are not kept in sync by any code — the
client must decide which is canonical for which surface (see OQ-4).
5. Permissions reality (read carefully)
permissions.constants.tscontains noteacher.*permissions — the teachers endpoints carry none ofstaff.*,department.manage,designation.manageeither (permissions.constants.ts:19-24).TeacherControlleris guarded only by@UseGuards(JwtAuthGuard)(teacher.controller.ts:18-21). The globalRbacGuard(app.module.ts:131) passes any request that carries no@Roles/@Permissionsmetadata (rbac.guard.ts:29).- The default
teacherrole ships with['student.read','attendance.mark','attendance.edit'](role.schema.ts:26-32) — nothing that lets a teacher read their own profile viaGET /teachers/:idis gated server-side; the client must enforce "teacher sees own record only" in UI/routing (OQ-1). docs/user-flows/END_TO_END_USER_FLOWS.md:751anticipatesteacher.*permissions ("✅ Admin, Read own — Teacher") — this is a doc-only contract, not implemented.
6. Events & side-effects
| Event | Emitted at | Queue route | Side-effect |
|---|---|---|---|
TeacherCreated | teacher.service.ts:40-50 (payload {teacherId, employeeNumber}) | in-app / job teacher-created (event-queue-map.ts:31) | In-app notification (inapp.worker.ts:46-53); search index Teacher (search-indexer.service.ts:10) |
TeacherUpdated | teacher.service.ts:84-91 (payload {teacherId}) | audit-write / log-teacher-updated (event-queue-map.ts:32) | Audit log; search re-index |
TeacherDeleted | teacher.service.ts:98-105 (payload {teacherId}) | audit-write / log-teacher-deleted (event-queue-map.ts:33) | Audit log; search index removal (search-indexer.service.ts:52-59) |
Two derivable gaps (see 14-QA): PLAN.md:36 promises "→ email queue → welcome email"
but the actual mapping sends TeacherCreated to the in-app queue, not emails;
and inapp.worker.ts:47 stores type: 'TeacherCreated' into a notification whose
NotificationType enum only admits email_verified|password_reset|verification_resent|welcome
(notification.schema.ts:7-12) — an enum-mismatch risk.
7. Dependencies
| Dependency | Role | Source |
|---|---|---|
| Users module | identity link (userId → firstName/lastName/email) | user.schema.ts:17-29; RELATIONSHIPS.md:22-30 |
| Staff module (Academics-owned) | departments, designations catalogs | staff.controller.ts (departments/designations controllers), department.schema.ts:9-19 |
| Academics module | subjects, classes, academic_years, subject_assignments | subject.schema.ts:9-34, class.schema.ts:9-34, academic-year.schema.ts:14-32 |
| Academics: class teacher | classes.classTeacherId | class.schema.ts:28 |
| Timetable module | teacher schedule (GET /timetable?teacherId=) | timetable.controller.ts:21-27, timetable.service.ts:55-60 |
| Dashboard module | teacher count KPI | dashboard.service.ts:32,52 |
| Leave module | substitute-teacher flows reference teacherRepo | leave.service.ts:232-277 |
BullMQ in-app / audit-write | event side-effects | queue.constants.ts:5,11, event-queue-map.ts:31-33 |
| Mongo collections | teachers, subject_assignments, subjects, classes, academic_years, departments, designations | COLLECTIONS.md:1418,1644 |
8. Business goals
| Goal | Measure |
|---|---|
| Create a teacher in < 1 min of form time | 2 server calls max (user exists → create profile) |
| No duplicate teachers | 409 on duplicate userId / employeeNumber (teacher.service.ts:27-38) |
| Assignment matrix accurate per academic year | subject_assignments scoped by academicYearId in every query (subject-assignment.service.ts:19-31) |
| Deactivation is safe & reversible by ops | soft-delete only; nothing is physically removed |
| Tenant isolation structural | every repo call injects tenantId + isDeleted:false (base.repository.ts:20-30) |
9. Success metrics
- Teacher creation → visible in list + dashboard count ≤ 3 s after submit.
- 0 duplicate employeeNumber errors for > 99% of form flows (client validates against existing list + server 409 handles races).
- Assignment editor: no silent conflicts — server has no conflict check on
POST /subject-assignments(subject-assignment.service.ts:13-17) so the client must surface duplicates itself (OQ-2). - Deactivate flow: confirmation dialog → success in 1 call, teacher disappears from all lists (soft-delete) but audit/search retention honored.
10. Edge cases (derived)
- Duplicate
userId→ 409 "Teacher profile already exists for this user." (teacher.service.ts:29-31). - Duplicate
employeeNumber→ 409 "Employee number "X" already exists." (teacher.service.ts:35-38). GET /teachers/:idunknown id → 404 "Teacher not found." (teacher.service.ts:56).DELETEunknown id → 404 (softDelete returns false → throw) (teacher.service.ts:96-97).PATCHunknown id → 404 viafindByIdpre-check (teacher.service.ts:81).GET /subject-assignments/by-teacher/:teacherIdwithoutacademicYearId→ filter is{teacherId, academicYearId: undefined}→ empty result (query param is required in practice though not validated) (subject-assignment.service.ts:26-31).DELETE /subject-assignments/:idunknown id → 404 "Assignment not found." (subject-assignment.service.ts:34-35).PATCH /teachers/:idcannot changeuserId(not inUpdateTeacherDto) — identity link is immutable via this API.sortandqparams are accepted byPaginationQueryDtobut not applied inTeacherService.find(teacher.service.ts:66-78) — client-side search/sort needed or backend enhancement (OQ-3).
11. Assumptions (module)
- Client is forward-looking: backend is complete for this module; these docs are the
UI-side spec, consistent with shared ledger A1 (
00-shared/12) — PRD lists native mobile as a later-phase, read-only companion; this package specs a full client per user instruction. subjects[]/classTeacherFor[]arrays andsubject_assignmentsare treated by the client as complementary: profile arrays render as badges/profile data; the assignments matrix drives the per-year schedule/assignment surfaces (OQ-4 for sync).- No bulk-import surface exists for teachers (
BulkModuleexists atapp.module.ts:59but teacher import is not implemented) → bulk import is(planned), not specced as v1. - Teacher photo/avatar: no photo field exists on
TeacherorUserin scope — avatar rendering uses initials fallback (AppAvatar,03_Component_Library.md).
12. Open questions (module-level)
| # | Item | Impact |
|---|---|---|
| OQ-1 | No teacher.* permissions exist and no endpoint carries RBAC metadata — who may view/edit which teacher? Is a self-service "my profile" endpoint (GET /teachers/me) planned? | Screen access rules, permission UI |
| OQ-2 | POST /subject-assignments has no duplicate/conflict guard (subject-assignment.service.ts:13-17) — duplicate (teacher,subject,class,year) is storable. Client-side dedup only, or backend unique index? | Assignment editor UX |
| OQ-3 | sort/q ignored by TeacherService.find (teacher.service.ts:66-78) — implement client-side filter/sort or extend backend? | List screen |
| OQ-4 | Three class-teacher sources (teacher.classTeacherFor[], classes.classTeacherId, none in assignments) — which is canonical for "class teacher" display? | Detail tabs |
| OQ-5 | Deactivation doesn't check active assignments/timetable/substitutions — deactivating a teacher with a live schedule is allowed. Guard added where? | Deactivate dialog copy |
| OQ-6 | TeacherCreated in-app notification writes a type outside the NotificationType enum (notification.schema.ts:7-12 vs inapp.worker.ts:47) — likely DB validation failure; needs backend fix before notifications render. | Notification UX |
02 — User Personas (Teachers Module)
- 1. Organization Admin
- 2. HR Admin (staff/people operations)
- 3. Teacher (self)
- 4. Academic Coordinator (teaching-pattern oversight)
- 5. Persona → surface map
Derived from
blueprint/01-Product/USER_PERSONAS.md(tenant-level actors) and the implemented RBAC role seed (src/modules/rbac/schemas/role.schema.ts:8-65). The blueprint defines Organization Admin, Teacher, and Staff (Non-Teaching) personas; HR Admin and Academic Coordinator are role-level specializations of Staff/Teacher inferred from blueprint descriptions and module access patterns — flagged as derived, not literal blueprint personas.
1. Organization Admin
- Blueprint anchor: "Manages a single institution (tenant). Goals: Configure
branches, invite staff, oversee operations. Pain: Wants one dashboard for everything.
Access: Full tenant admin, configurable permissions." (
USER_PERSONAS.md:20-25) - System role:
org_admin—permissions: ALL_PERMISSIONS(role.schema.ts:17-24). Note: there are noteacher.*perms in the system (permissions.constants.ts:1-97) — the admin's access to teachers is inherited as everything, not by explicit permission. - Goals: onboard teachers fast (create → assign → ready to teach), keep the staff
roster accurate, deactivate leavers safely, see teacher counts on dashboard
(
dashboard.service.ts:32,52). - Pain points: duplicate employee numbers cause 409 on create
(
teacher.service.ts:32-38); no server-side assignment conflict check (subject-assignment.service.ts:13-17) means errors surface late;sort/qquery params are ignored (teacher.service.ts:66-78) so large rosters are hard to scan. - Context: desktop-first, but reviews notifications on phone (in-app
teacher-creatednotifications,event-queue-map.ts:31). - Success: roster complete within one session; every new teacher has subjects + classes before their first class.
2. HR Admin (staff/people operations)
- Blueprint anchor: Staff persona — "Operations, reception, coordinators. Goals:
Manage profiles, support admins. Pain: Needs read access across modules. Access:
Role-scoped, often read-heavy." (
USER_PERSONAS.md:34-39) — HR is the profile-lifecycle operator within that persona. (derived) - System role: custom
staffrole —permissions: ['student.read'](role.schema.ts:34-36); or a custom role withstaff.read/updateperpermissions.constants.ts:19-22(client-side gating only, see 01 §5). - Goals: create teacher profiles from onboarding paperwork (employee number, department, designation, joining date), update qualifications/experience, mark status changes, deactivate leavers.
- Pain points: teacher form requires a pre-existing User (
userIdis the only required identity field,create-teacher.dto.ts:5-7) — HR must create the user in Users module first (cross-module flow, OQ-1). - Context: mostly desktop web; repeated entry; values consistency
(employeeNumber format like
TCH001,create-teacher.dto.ts:9).
3. Teacher (self)
- Blueprint anchor: "Delivers instruction. Goals: Mark attendance, assign homework,
enter exam marks. Pain: Wants minimal clicks; mobile-friendly. Access: Own classes,
own students." (
USER_PERSONAS.md:27-32); module map: "Teacher | Attendance, Homework, Exams, Results" (USER_PERSONAS.md:85). - System role:
teacher—permissions: ['student.read','attendance.mark','attendance.edit'](role.schema.ts:26-32). No teacher. permission, no self-profile endpoint* (OQ-2) — "My schedule" is reachable viaGET /timetable?teacherId=<me>(timetable.controller.ts:21-27) andGET /subject-assignments/by-teacher/<me>(subject-assignment.controller.ts:30-35) using their own teacher id. - Goals: see today's/this term's teaching load (subject-assignment matrix), check which classes they're class-teacher for, confirm profile basics are right, minimal clicks, mobile.
- Pain points: no self-service screen exists in the backend — the client must
compose one from assignments + timetable; status
on_leaveshould suppress schedule emphasis but nothing server-side does that. - Context: phone-first during school hours; check "what do I teach / where do I go" in < 10 s.
4. Academic Coordinator (teaching-pattern oversight)
- Blueprint anchor: derived from Staff persona ("coordinators… read access across
modules",
USER_PERSONAS.md:34-39) + the Academics module being the assignment owner (subject-assignment.controller.ts:16-17). (derived — not a literal blueprint persona) - System role: custom role holding read perms (
student.read,timetable.read,settings.readwhere granted) — again client-gated. - Goals: review the per-year teaching matrix: who teaches what in which class; balance workload; spot missing subject coverage; assign a teacher to a class-subject for the new academic year.
- Pain points: duplicate assignments are storable (no unique index,
subject-assignment.schema.ts:24-25), so a coordinator can double-assign; two assignment stores (teacher.subjects[]vssubject_assignments) can disagree. - Context: desktop, term-start bursts (August–September), report-style views.
5. Persona → surface map
| Persona | Primary surfaces (this module) | Key endpoints |
|---|---|---|
| Org Admin | Teachers list, detail, create/edit, deactivate, dashboard KPI | GET/POST/PATCH/DELETE /teachers (teacher.controller.ts:24-38) |
| HR Admin | Create/edit teacher, status changes, deactivate | POST/PATCH/DELETE /teachers |
| Teacher (self) | My profile, My assignments, My schedule | GET /subject-assignments/by-teacher/:teacherId (subject-assignment.controller.ts:30), GET /timetable?teacherId= (timetable.controller.ts:21-27) |
| Academic Coordinator | Assignment editor, by-class view | POST /subject-assignments, GET /subject-assignments/by-class/:classId (subject-assignment.controller.ts:21-29) |
03 — User Journeys (Teachers Module)
- Journey 1 — Create a teacher (Org Admin / HR)
- Journey 2 — Assign subjects/classes (Org Admin / Academic Coordinator)
- Journey 3 — Edit teacher profile (HR / Admin)
- Journey 4 — Deactivate a teacher (HR / Admin)
- Journey 5 — Teacher self-view: profile, assignments, schedule (Teacher)
Five journeys mapped 1:1 to the implemented API. Endpoint citations:
teacher.controller.ts:24-38,subject-assignment.controller.ts:21-38,timetable.controller.ts:21-27.(planned)= roadmap/documented but not in code.
Journey 1 — Create a teacher (Org Admin / HR)
flowchart TD
A[Start: Users list] --> B[Find/create User account]
B --> C[Open Teachers list]
C --> D[FAB 'Add teacher' → Create form]
D --> E{Fill form: userId, employeeNumber, department,\n designation, joiningDate, status, subjects, classes}
E --> F[Submit POST /teachers]
F --> G{Server}
G -->|409 DUPLICATE_RESOURCE| H[Inline error: user already has profile\n OR employeeNumber exists]
G -->|400 VALIDATION_ERROR| I[Field errors per DTO]
G -->|200| J[TeacherCreated event]
J --> K[In-app notification: teacher-created]
J --> L[Search index: Teacher]
J --> M[Success snackbar → navigate to detail]
- Steps:
POST /teacherswithCreateTeacherDto(create-teacher.dto.ts:4-52). - Business rules:
findByUserId→ 409 (teacher.service.ts:27-31);findByEmployeeNumber→ 409 (teacher.service.ts:32-38). - Side-effects:
TeacherCreated→in-appqueue jobteacher-created(event-queue-map.ts:31) → notification (inapp.worker.ts:46-53); search index upsert (search-indexer.service.ts:10,90-96). - Note:
PLAN.md:36says "→ email queue → welcome email" — the code routes toin-app, notemails(see01 §6, QA-12). - Failure exits: 401 (expired token → refresh), 429 (backoff), 5xx (generic + requestId).
Journey 2 — Assign subjects/classes (Org Admin / Academic Coordinator)
flowchart TD
A[Teacher detail → Assignments tab] --> B[Pick academic year\ndefault = current]
B --> C[Load matrix GET /subject-assignments/by-teacher/:id?academicYearId=]
C --> D[Empty state: 'No assignments this year'\n CTA 'Add assignment']
D --> E[Assignment editor: subject × class × year]
E --> F{Duplicate check client-side\n teacher+subject+class+year exists?}
F -->|yes| G[Block: inline conflict message]
F -->|no| H[POST /subject-assignments]
H --> I{Server}
I -->|200| J[Append row to matrix]
I -->|400/404| K[Error banner, no state change]
I -->|500| L[Generic error + requestId]
J --> M[Optional: DELETE /subject-assignments/:id\n to remove mis-assignment]
- Sources:
subject-assignment.service.ts:13-17(create — no server dup check),subject-assignment.service.ts:26-31(byTeacher, requiresacademicYearId),subject-assignment.schema.ts:24-25(non-unique indexes). academicYearIdcomes fromGET /academic-years(academic-year.controller.ts:27-30) withisCurrentflag (academic-year.schema.ts:31-32).- Class picker:
GET /classes?academicYearId(class.controller.ts:27-33); subject picker:GET /subjects(subject.controller.ts:27-30).
Journey 3 — Edit teacher profile (HR / Admin)
flowchart TD
A[Teachers list → row] --> B[Teacher detail → Profile tab]
B --> C[Edit button → Edit form prefilled from GET /teachers/:id]
C --> D[Change fields: department, designation, joiningDate,\n status, qualification, experienceYears, subjects, classes]
D --> E[Submit PATCH /teachers/:id]
E --> F{Server}
F -->|200| G[TeacherUpdated → audit-write log-teacher-updated]
F -->|404| H['Teacher not found' → return to list]
F -->|400| I[Field errors]
G --> J[Snackbar saved → detail refresh]
UpdateTeacherDto(update-teacher.dto.ts:4-53) —userIdimmutable,metadataadded (only in update DTO,update-teacher.dto.ts:51-53).- Side-effect:
TeacherUpdated→audit-write(event-queue-map.ts:32) + search re-index (search-indexer.service.ts:17). - Server pre-check
findByIdbefore$set(teacher.service.ts:80-83) → PATCH of a soft-deleted teacher returns 404 automatically (soft-delete scope).
Journey 4 — Deactivate a teacher (HR / Admin)
flowchart TD
A[Teacher detail → overflow menu 'Deactivate'] --> B[Confirm dialog:\n 'Deactivate {name}?\n Profile will be hidden from lists.\n Assignments remain in history.']
B --> C[Confirm → DELETE /teachers/:id]
C --> D{Server}
D -->|200| E[TeacherDeleted → audit-write log-teacher-deleted\n + search index removal]
D -->|404| F['Teacher not found' → treat as already deactivated\n → return to list]
E --> G[Snackbar 'Teacher deactivated'\n → navigate back to list (row gone)]
- Soft-delete mechanics:
base.repository.ts:68-74setsisDeleted/deletedAt/deletedBy, all reads exclude (base.repository.ts:20-30). - No server-side guard on active assignments/timetable/substitutions (OQ-5) — dialog copy must warn that schedule data is not cleaned up.
- No re-activation endpoint exists → deactivate is irreversible via API (manual DB restore only). Dialog states this.
Journey 5 — Teacher self-view: profile, assignments, schedule (Teacher)
flowchart TD
A[Login as teacher] --> B[Nav 'My Teaching' (client-side surface)]
B --> C[GET /subject-assignments/by-teacher/:teacherId?academicYearId=]
B --> D[GET /timetable?teacherId=<me>]
B --> E[GET /teachers/:id (own record)]
C --> F[Term overview: subject × class cards]
D --> G[Weekly grid: dayOfWeek × startTime, sorted\n timetable.service.ts:55-60]
E --> H[Profile summary: department, designation,\n status, qualification]
F --> I[If status on_leave → banner 'On leave'\n emphasis off]
G --> J[Tap period → context: attendance/homework\n (other modules, no navigation from backend)]
teacherIdfor self: client resolves fromGET /teachers/:id? There is no/teachers/me— client needs the teacher record linked to the logged-in user (OQ-2). Options: resolve viaGET /teacherslist scan (O(n) — bad), or aGET /teachers/:idper recorded id; backend support for "me" is(planned).- Schedule source:
GET /timetable?teacherId=(timetable.controller.ts:21-27) — note the controller's query param isteacherId, soteacherId=mestring fromEND_TO_END_USER_FLOWS.md:266is doc-fiction; the client must send the real id. - Sort is server-side:
{dayOfWeek:1, startTime:1}(timetable.service.ts:56-59).
04 — Information Architecture (Teachers Module)
- 1. Placement in the app shell
- 2. Sitemap (client, this module)
- 3. Screen relationships
- 4. Navigation details
- 5. Role → surface gating (client-side; server enforces JWT only —
01 §5) - 6. Information units (primary entities)
- 7. Empty-state strategy
Fits the shared shell (00-shared/05_Global_Information_Architecture.md). Teachers is a Staff-adjacent, Academics-linked module: routes live under a
People/Staffdestination, while assignment data lives in the Academics domain.
1. Placement in the app shell
- Primary home:
Staffdestination (/staff, role-gated admin/HR per 05_Global_Information_Architecture.md:39) — teachers and staff share the "people/employment" cluster (same user+department+designation model). - Teacher self surface:
(planned)dedicated "My Teaching" entry for theteacherrole — today the client composes it from timetable + assignments (03 §5). Not a top-level destination in the shared IA (Teachers isn't listed there at all — derivation: onlyStaffexists). - Cross-links: Academics (subjects/classes/years), Timetable (schedule), Users (identity), Notifications (teacher-created), Dashboard (count KPI).
2. Sitemap (client, this module)
Staff (People)
└── Teachers
├── Teachers List /staff/teachers
│ ├── Teacher Detail /staff/teachers/:id
│ │ ├── Tab: Profile (default)
│ │ ├── Tab: Assignments (per academic year)
│ │ └── Tab: Schedule (timetable by teacherId)
│ ├── Create Teacher /staff/teachers/new
│ └── Edit Teacher /staff/teachers/:id/edit
└── Assignment Editor (sheet/dialog) reachable from Detail·Assignments
Teacher (self, planned)
└── My Teaching /my/teaching
├── My Profile (read-only)
├── My Assignments
└── My Schedule
3. Screen relationships
- List → Detail (push on phone; master-detail right-pane ≥ 840 dp,
05_Global_Information_Architecture.md:58). - Detail → Edit form (returns → detail refresh).
- Detail → Assignment editor (bottom sheet on phone, dialog on desktop,
03_Component_Library.mdAppBottomSheet). - Detail → Schedule tab (client-side tab within detail; data from Timetable module).
- Deactivate is a dialog on the detail screen (destructive,
AppDialog), not a page.
4. Navigation details
| Aspect | Rule |
|---|---|
| Route params | :id = teacher _id (Mongo ObjectId, teacher.schema.ts) |
| Tab state | StatefulShellBranch keeps tab index across navigation (05 §3) |
| Deep link | studylyon://teachers/:id (teacher detail); studylyon://teachers/:id/assignments — (planned), no backend constraint |
| Breadcrumbs | desktop detail screens: Staff › Teachers › {name} (05 §3) |
| FAB | List screen only: "Add teacher" (05 §7); hidden on scroll-down |
| Context menu | Row long-press / right-click: Edit, Deactivate (03_Component_Library.md AppMenu) |
| Keyboard | +/N = new teacher on list (desktop, 05 §7) |
5. Role → surface gating (client-side; server enforces JWT only — 01 §5)
| Surface | org_admin | HR/custom (staff.read+) | teacher (self) | staff read-only |
|---|---|---|---|---|
| Teachers list | full | full | — | read ((proposed) — staff.read exists at permissions.constants.ts:19) |
| Teacher detail | full | full | own only (planned) | read |
| Create / edit / deactivate | full | full | — | — |
| Assignments (by teacher / by class) | full | full | own (read) | read |
| My Teaching | — | — | full | — |
Server reality: no endpoint carries role metadata (rbac.guard.ts:29 passes
metadata-less routes) → the client is the only gate. Cross-tenant access is still
structurally blocked server-side via scopedFilter (base.repository.ts:20-30).
6. Information units (primary entities)
| Entity | Collection | Key fields | Client model |
|---|---|---|---|
| Teacher | teachers (COLLECTIONS.md:1418) | userId, employeeNumber, departmentId, designationId, joiningDate, employmentStatus, qualification, experienceYears, subjects[], classTeacherFor[], metadata | Teacher |
| SubjectAssignment | subject_assignments (COLLECTIONS.md:1644) | teacherId, subjectId, classId, academicYearId | SubjectAssignment |
| Department / Designation | departments / designations (Staff-owned) | name, code, level, status | Department, Designation (reference) |
| Subject | subjects (COLLECTIONS.md:1598) | code, name, shortName, credits, marks | Subject (reference) |
| Class | classes (COLLECTIONS.md) | academicYearId, gradeId, sectionId, name, classTeacherId | Class (reference) |
| AcademicYear | academic_years (COLLECTIONS.md) | name, startDate, endDate, status, isCurrent | AcademicYear (reference) |
7. Empty-state strategy
- List with zero teachers →
AppEmptyState"No teachers yet — add your first teacher". - Detail with zero assignments → inline empty per tab, CTA "Add assignment".
- Schedule tab with zero timetable entries → "No classes scheduled".
- List with filters returning zero → "No teachers match filters" + clear-filters action.
05 — Screen Inventory (Teachers Module)
- Screen index
- S1 — Teachers List
- S2 — Teacher Detail · Profile tab
- S3 — Teacher Detail · Assignments tab
- S4 — Teacher Detail · Schedule tab
- S5 — Create Teacher form (full page)
- S6 — Edit Teacher form (full page)
- S7 — Subject Assignment Editor (bottom sheet / dialog)
- S8 — Deactivate confirmation dialog
- S9/S10 — My Teaching / My Schedule (teacher self,
(planned))
All screens are client-side compositions over the implemented API (
teacher.controller.ts:24-38,subject-assignment.controller.ts:21-38,timetable.controller.ts:21-27).(planned)= needs backend/roadmap work.
Screen index
| # | Screen | Route | Type | Access |
|---|---|---|---|---|
| S1 | Teachers List (filters + pagination) | /staff/teachers | List | admin, HR (staff.read client-gated) |
| S2 | Teacher Detail — Profile tab | /staff/teachers/:id | Detail (tabs) | admin, HR; own-record (planned) for teacher |
| S3 | Teacher Detail — Assignments tab | /staff/teachers/:id (tab 2) | Detail (tabs) | admin, HR, coordinator |
| S4 | Teacher Detail — Schedule tab | /staff/teachers/:id (tab 3) | Detail (tabs) | admin, HR, teacher self |
| S5 | Create Teacher form | /staff/teachers/new | Form (full page) | admin, HR |
| S6 | Edit Teacher form | /staff/teachers/:id/edit | Form (full page) | admin, HR |
| S7 | Subject Assignment Editor | sheet/dialog on S3 | Editor | admin, coordinator |
| S8 | Deactivate confirmation | dialog on S2 | Dialog (destructive) | admin, HR |
| S9 | My Teaching (teacher self) | /my/teaching (planned) | Dashboard-ish summary | teacher |
| S10 | My Schedule (teacher self) | /my/teaching/schedule (planned) | List/grid | teacher |
Supporting pickers reused from other modules (not screens of this module): users picker (S5), departments/designations pickers (S5/S6), subjects picker (S5/S7), classes picker (S5/S7), academic-year picker (S7).
S1 — Teachers List
- Purpose: browse, search, filter, paginate the teacher roster.
- Elements:
AppSearchBar(q), filter chips (employment status × 4,teacher.schema.ts:7-12; department; designation),AppListTilerows (avatar/initials, display name, employeeNumber, status chip, department), pagination viaPaginatedListMixin(infinite scroll, 06_State_Management.md §3.2), FAB "Add teacher". - Data:
GET /teachers?page=&limit=&sort=&q=(teacher.controller.ts:27-29). Caveat:sort/qnot applied server-side (teacher.service.ts:66-78) → client re-sorts cached page; server search(planned)(OQ-3). - States: skeleton → content | empty | error (retry). Filter change = refetch page 1.
S2 — Teacher Detail · Profile tab
- Purpose: read-only profile; entry point for edit/deactivate.
- Elements: header card (avatar initials, name, employeeNumber, status chip), info
rows (department, designation, joining date, qualification, experienceYears,
teacher.schema.ts:16-51), subjects badges (subjects[]), class-teacher badges (classTeacherFor[]), metadata section when present, AppBar actions: Edit, overflow menu (Deactivate). - Data:
GET /teachers/:id(teacher.controller.ts:30-32). 404 → treat as removed (empty state + back). - Enrichment: department/designation/subject/class names resolved client-side from reference catalogs (04 §6); server returns raw ObjectIds only.
S3 — Teacher Detail · Assignments tab
- Purpose: per-academic-year teaching matrix for this teacher.
- Elements:
AppDropdownacademic-year (defaultisCurrent,academic-year.schema.ts:31-32), list ofSubjectAssignmentrows (subject name, class name, year), "Add assignment" button → S7, row overflow: Remove (confirm dialog). - Data:
GET /subject-assignments/by-teacher/:teacherId?academicYearId=(subject-assignment.controller.ts:30-35). Empty result whenacademicYearIdomitted (subject-assignment.service.ts:26-31) → picker always set. - States: year with none →
AppEmptyState+ CTA; loading skeleton rows.
S4 — Teacher Detail · Schedule tab
- Purpose: weekly teaching timetable for this teacher.
- Elements: week grid (Mon–Sun × time slots), cards per period (subject/class/ startTime–endTime), empty state.
- Data:
GET /timetable?teacherId=<id>(timetable.controller.ts:21-27,timetable.service.ts:55-60, sorteddayOfWeek, startTime). - Caveat:
teacherId=memagic value fromEND_TO_END_USER_FLOWS.md:266is not implemented — always send the real id (OQ-2).
S5 — Create Teacher form (full page)
- Purpose: create a teacher profile; must reference an existing User.
- Fields (server contract,
create-teacher.dto.ts:4-52): userId* (user picker), employeeNumber*, departmentId, designationId, joiningDate, employmentStatus (defaultactive), qualification, experienceYears, subjects[] (multi), classTeacherFor[] (multi). - Submit:
POST /teachers→ 200 data = created teacher (teacher.controller.ts:24-26). - Errors: 409 duplicate user/employeeNumber (
teacher.service.ts:27-38) shown inline; 400 field errors mapped (00-shared/07 §3).
S6 — Edit Teacher form (full page)
- Same field set as S5 minus userId, plus optional
metadataobject (update-teacher.dto.ts:4-53). Prefilled fromGET /teachers/:id. - Submit:
PATCH /teachers/:id→ 200 updated doc (teacher.controller.ts:33-35). - Identity link immutable (userId absent from DTO).
S7 — Subject Assignment Editor (bottom sheet / dialog)
- Purpose: create one
SubjectAssignment(teacher × subject × class × year). - Fields (
create-subject-assignment.dto.ts:4-19): teacherId (prefilled from detail), subjectId* (picker), classId* (picker, year-scoped), academicYearId* (picker, default current). - Submit:
POST /subject-assignments→ 200 doc (subject-assignment.controller.ts:21-23). - Client-side duplicate guard: block if (teacher, subject, class, year) already in
matrix — server has no check (
subject-assignment.service.ts:13-17) (OQ-2).
S8 — Deactivate confirmation dialog
- Purpose: confirm irreversible soft-delete.
- Content: title "Deactivate teacher?", body: name + employeeNumber; warning copy: hidden from all lists; historical records (assignments, audit) retained; no reactivation via API (OQ-5).
- Action:
DELETE /teachers/:id(teacher.controller.ts:36-38) → 200 void → snackbar, navigate back. 404 → "Already deactivated" → back.
S9/S10 — My Teaching / My Schedule (teacher self, (planned))
- Composition over existing endpoints:
GET /subject-assignments/by-teacher/:id(assignments),GET /timetable?teacherId=(schedule),GET /teachers/:id(profile). - Requires resolution of "my teacher id" — no
/teachers/meendpoint (teacher.controller.ts:24-38has none) (OQ-2).
06 — Screen Specifications (Teachers Module)
- S1 — Teachers List
- S2 — Teacher Detail · Profile tab
- S3 — Teacher Detail · Assignments tab
- S4 — Teacher Detail · Schedule tab
- S5 — Create Teacher form
- S6 — Edit Teacher form
- S7 — Subject Assignment Editor (bottom sheet on phone, dialog on desktop)
- S8 — Deactivate confirmation dialog
- S9/S10 — My Teaching / My Schedule (teacher self) —
(planned)
The definitive per-screen spec. Wire contract per 00-shared/07. All field names and rules cite the DTO/schema they come from.
(planned)= backend/roadmap gap.
S1 — Teachers List
Layout (phone / tablet / desktop)
AppBar: "Teachers" | [Search icon → /search]
─────────────────────────────────────────────
AppSearchBar (q) [Filter chips ▾]
[Filter chips row: status · department · designation]
─────────────────────────────────────────────
[AppListTile × N] avatar Name (titleMedium)
Employee #TCH001 · Dept (bodyMedium)
[StatusChip] [›]
─────────────────────────────────────────────
[infinite scroll loader / end-of-list footer]
FAB: "Add teacher"
- Breakpoints: phone single column list; tablet 2-column grid of
AppCards; desktop master-detail (tap row → detail in right pane,05_Global_Information_Architecture.md:58).
Behaviour & states
| State | UI | Source |
|---|---|---|
| Initial/Loading | 8 × AppSkeleton rows | 06_State_Management.md §3.1 |
| Success + data | list, status chips colored | — |
| Success + empty (no filters) | AppEmptyState "No teachers yet" + CTA add | — |
| Success + empty (filters) | AppEmptyState "No teachers match" + clear filters | — |
| Error | AppErrorState(code, message, retry) | envelope error.code |
Data & interactions
- Fetch:
GET /teachers?page=&limit=20(teacher.controller.ts:27-29);metadriveshasNext(buildPaginationMeta,pagination-query.dto.ts:41-55). q/sortparams: sent but ignored byTeacherService.find(teacher.service.ts:66-78) — client filters cached list by q locally;(planned)server search (OQ-3).- Filter chips: status (4 enum values,
teacher.schema.ts:7-12), department, designation — no server filter params exist → client-side filtering of loaded pages (OQ-3); document as limitation. - Row tap → S2. Overflow menu: Edit → S6, Deactivate → S8.
- Pull-to-refresh (
RefreshIndicator) bypasses cache; FAB scroll-hide behaviour.
Accessibility
- Chips:
FilterChipwith selected state semantics (09_Accessibility_Baseline.md §5). - Status never color-only: chip = icon + label (status enum text) (
09 §9). - Live region announces search result count.
S2 — Teacher Detail · Profile tab
Layout
AppBar: "{displayName}" [Edit] [⋮ menu]
─────────────────────────────────────────────
Header card (AppCard, tonal):
[Avatar 48] Name (titleLarge) StatusChip (labelLarge)
Employee #TCH001 (mono)
─────────────────────────────────────────────
Info section (AppSectionHeader "Employment"):
InfoRow Department "Science"
InfoRow Designation "Teacher" (designations examples,
InfoRow Joining date "12 Aug 2024" COLLECTIONS.md:1560-1574)
InfoRow Qualification "M.Sc. Physics"
InfoRow Experience "6 years"
AppSectionHeader "Subjects taught": [SubjectBadge × N]
AppSectionHeader "Class teacher for":[ClassBadge × N]
AppSectionHeader "Metadata" (only if non-empty): key/value rows
Data & states
GET /teachers/:id(teacher.controller.ts:30-32). 404 →AppErrorStateRESOURCE_NOT_FOUND + "back to list" (treat as removed/soft-deleted).- Reference names resolved from cached catalogs; missing ref id → "—".
- Empty
subjects[]/classTeacherFor[]→ "Not set" bodyMedium text, no empty-state.
Interactions
- Edit → S6 (prefill from current doc).
- Menu → Deactivate (S8). If
employmentStatus === 'terminated'→ deactivate hidden. - If
on_leave→AppBannerinfo: "On leave since {updatedAt}".
S3 — Teacher Detail · Assignments tab
Layout
AppSectionHeader "Assignments" [Academic year ▾] [+ Add]
─────────────────────────────────────────────────────
[AssignmentCard × N]
subject name (titleMedium) [⋮ remove]
class name · academic year (bodyMedium)
─────────────────────────────────────────────────────
empty: AppEmptyState "No assignments for {year}" + CTA
Data & states
GET /subject-assignments/by-teacher/:teacherId?academicYearId=(subject-assignment.controller.ts:30-35);academicYearIdrequired in practice (subject-assignment.service.ts:26-31returns [] when missing).- Year picker from
GET /academic-years(academic-year.controller.ts:27-30), default =isCurrent(academic-year.schema.ts:31-32). - Loading: 3 skeleton cards. Error: inline
AppErrorState(compact)+ retry. - Remove: confirm
AppDialog→DELETE /subject-assignments/:id(subject-assignment.controller.ts:36-38); 404 → treat as already removed.
S4 — Teacher Detail · Schedule tab
Layout
AppSectionHeader "Weekly schedule" [Week ▾ (current week)]
Grid: 7 columns (Mon..Sun) × time rows; period cards:
"10:00–10:45 · Mathematics · 10-A"
Data & states
GET /timetable?teacherId=<id>(timetable.controller.ts:21-27), server-sorteddayOfWeek, startTime(timetable.service.ts:55-60).- Empty → "No classes scheduled." Stale-year handling: show current term by default.
S5 — Create Teacher form
Layout & flow
AppBar "Add teacher" [Cancel] [Save]
─────────────────────────────────────────
1. Identity user picker* (from Users list, email searchable)
2. Employment employeeNumber* (mono, e.g. TCH001)
3. Org placement department ▾ · designation ▾ (pickers from staff catalogs)
4. Timeline joiningDate (date picker) · employmentStatus ▾ (default active)
5. Qualifications qualification (multiline) · experienceYears (number)
6. Assignments subjects multi-select (chips) · classTeacherFor multi-select
[Save] sticky bottom on phone; top-right on desktop
Field-by-field validation (server DTO vs client)
| Field | Server (create-teacher.dto.ts) | Client rules |
|---|---|---|
| userId | required @IsMongoId (line 6-7) | required; from user picker |
| employeeNumber | required @IsString (10-11) | required; trim; unique check against loaded roster (server 409 = race-safe) |
| departmentId | optional @IsMongoId (15-16) | picker |
| designationId | optional @IsMongoId (20-21) | picker |
| joiningDate | optional @IsDateString (25-26) | date picker; ≤ today (proposed) |
| employmentStatus | optional string, enum hint ['active','inactive','on_leave','terminated'] (28-33) | default active; server does not enum-validate → client restricts to 4 values |
| qualification | optional @IsString (37-38) | free text |
| experienceYears | optional number, no validator (41-42) | client: int ≥ 0 (proposed) |
| subjects | optional string[], no array/MongoId validator (45-46) | client: array of valid ObjectIds |
| classTeacherFor | optional string[], no validator (48-52) | client: array of valid ObjectIds |
Submit & errors
POST /teachers→ 200data= teacher doc (unwrapped by envelope interceptor).- 409 → inline
AppBanner(error)per field ("A teacher profile already exists for this user." / "Employee number … already exists.",teacher.service.ts:29-37). - 400 → map
error.details[].fieldto form fields (00-shared/07 §3). - Double-submit guard (anti-double-submit,
08_Interaction_&_Motion.md §6). - Offline → blocked with banner (no offline write queue for teachers,
00-shared/06 §3.7).
S6 — Edit Teacher form
- Same spec as S5 with: userId field hidden & immutable (
update-teacher.dto.ts:4-8lacks userId), metadata editor(proposed)simple key/value rows (update-teacher.dto.ts:51-53), prefilled values from detail doc. - Submit
PATCH /teachers/:id→ 200 updated doc; 404 → leave form + list. metadataisRecord<string, unknown>— client sends only primitive values(proposed).
S7 — Subject Assignment Editor (bottom sheet on phone, dialog on desktop)
Layout
Sheet "Assign {teacherName}" [Add]
Subject ▾ (required, searchable picker, GET /subjects)
Class ▾ (required, year-scoped, GET /classes)
Academic year ▾ (default isCurrent)
[Add] disabled until all three set
Rules
- All four ids
@IsMongoIdrequired (create-subject-assignment.dto.ts:5-19); teacherId prefilled from context (S3) or user-selected. - Client duplicate guard: exact (teacher, subject, class, academicYear) already in matrix → disable Add + inline error "Already assigned" (server gap, OQ-2).
- Submit
POST /subject-assignments→ 200 doc appended to matrix; snackbar "Assigned". - Errors: 400 → inline; 500 → generic + requestId; no optimistic insert (server
confirms only,
00-shared/06 §3.5).
S8 — Deactivate confirmation dialog
| Aspect | Spec |
|---|---|
| Title | "Deactivate {displayName}?" |
| Body | "{employeeNumber} will be hidden from all lists. Historical assignments and audit records are kept. This action cannot be undone in the app." |
| Actions | [Cancel] [Deactivate — destructive AppButton, error colors] |
| Flow | DELETE /teachers/:id → 200 → snackbar "Teacher deactivated" → pop to list; 404 → snackbar "Already deactivated" → pop |
| Focus | trapped in dialog; ESC/back cancels (03_Component_Library.md AppDialog) |
| A11y | Semantics destructive-action label; haptic heavyImpact() on confirm (08 §3) |
S9/S10 — My Teaching / My Schedule (teacher self) — (planned)
- Spec deferred to backend support for self-resolution (OQ-2); wire behaviour per S3/S4 with read-only treatment (no add/remove for teacher role).
- Own-profile banner when
on_leave: "Your profile is on leave — schedule may be reassigned."
07 — Component Library (Teachers Module)
- A. Module components
- TeacherListTile (extends AppListTile)
- StatusChip (employment status)
- TeacherCard (desktop grid variant, extends AppCard)
- AssignmentCard (extends AppCard)
- AssignmentMatrixHeader
- EmptyAssignmentsState (extends AppEmptyState)
- SubjectBadge / ClassBadge (extends AppBadge + AppChips)
- TeacherAvatar (extends AppAvatar)
- TeacherCountStat (extends AppStatTile)
- MyTeachingHeader (teacher self,
(planned))
- B. Reused shared components
- C. Composition rules
Module-specific components; all reference the shared library (00-shared/03). Every component listed here is a client composition over the implemented API — none invent backend data.
A. Module components
TeacherListTile (extends AppListTile)
- Props:
teacher(id, name, employeeNumber, status, departmentLabel),onTap,onMenu(Edit/Deactivate),selected?. - Content:
AppAvatarinitials (name), title = display name, subtitle =employeeNumber · department, trailing =StatusChip+ overflowAppMenu. - Tokens:
surfacerow, height 56 (02_Design_Tokens.md §3). - A11y: combined label "Ramesh Kumar, TCH001, Science, active".
StatusChip (employment status)
- Props:
status: EmploymentStatus(teacher.schema.ts:7-12). - Mapping (icon + label — never color-only, 09 §9):
| Status | Icon (Material Symbols) | Container token | Label |
|---|---|---|---|
active | check_circle (filled) | secondaryContainer | Active |
inactive | pause_circle | surfaceVariant | Inactive |
on_leave | beach_access | tertiaryContainer | On leave |
terminated | person_off | errorContainer | Terminated |
- Implementation:
AppBadge+ icon; FlutterChip/customContainer.
TeacherCard (desktop grid variant, extends AppCard)
- Content: header avatar+name+status, body: 3 key InfoRows (dept, designation,
employeeNumber), footer: subject count + class count (
teacher.schema.ts:44-48). - Props:
teacher,onTap,elevation: e-1.
AssignmentCard (extends AppCard)
- Props:
assignment(subjectLabel, classLabel, yearLabel),onRemove?. - Content: subject (titleMedium) / class · year (bodyMedium) / trailing remove icon (tooltip "Remove assignment").
- A11y: card tap = one button semantics; remove icon separate.
AssignmentMatrixHeader
- Props:
year(currentAcademicYear),onYearChanged,onAdd. - Renders
AppSectionHeader+AppDropdown(academic year) + tonal add button ("Add assignment").
EmptyAssignmentsState (extends AppEmptyState)
- Icons:
menu_book; copy per context (teacher/class/year).
SubjectBadge / ClassBadge (extends AppBadge + AppChips)
- Props:
label,onDelete?. Read-only chips on profile; input-chip mode in multi-select fields (S5). - Max chips shown before "+N more" (list row overflow),
(proposed).
TeacherAvatar (extends AppAvatar)
- Initials from
firstName/lastNameof linked User (user.schema.ts:17-23) — client resolves from user reference; no photo field exists (fallback always).
TeacherCountStat (extends AppStatTile)
- Dashboard integration:
teachers.totalfromGET /dashboard/overview(dashboard.service.ts:32,52); tap → S1.
MyTeachingHeader (teacher self, (planned))
- Banner + summary tiles (assignments count, periods this week) — composed client-side.
B. Reused shared components
| Shared component | Used in |
|---|---|
AppSearchBar | S1 (q) |
AppSkeleton | S1/S2/S3/S4 loading |
AppEmptyState | S1 empty, S3 empty, S4 empty |
AppErrorState | S1/S3 errors (envelope code) |
AppBanner | S5 409 conflict, S2 on-leave |
AppSnackbar | save/deactivate/remove confirmations |
AppDropdown | year picker (S3), department/designation/subject/class pickers (S5/S7) |
AppSearchBar-style searchable picker | user picker (S5) |
AppDatePicker | joiningDate (S5/S6) |
AppTextField | employeeNumber, qualification, experienceYears (S5/S6) |
AppChips (filter) | status/department/designation filters (S1) |
AppTabs | profile/assignments/schedule (S2-S4) |
AppDialog | S8 deactivate, assignment remove, duplicate warning |
AppBottomSheet | S7 assignment editor (phone), pickers |
AppMenu | row/detail overflow menus |
AppFAB | S1 "Add teacher" |
AppButton | all CTAs (filled/tonal/outlined/destructive) |
AppInfoRow | S2 profile rows, S1 card body |
AppOfflineBanner | module-wide offline reads |
AppSectionHeader | S2/S3/S4 sections |
AppProgress | submit spinners, page loaders |
AppStatTile | S9 summary, dashboard |
C. Composition rules
- No screen renders raw ObjectIds — every id resolved via reference-catalog lookup or
—fallback (04 §6). - Status color mapping centralized in
StatusChip— no ad-hoc status colors in screens (enforced, 04_Design_System_Mapping.md §7). - Golden tests:
StatusChip(4 states × light/dark),TeacherListTile,AssignmentCard,TeacherCard(3 sizes), per 00-shared/03 §G. - New components only if shared library lacks the need (YAGNI — check shared first).
08 — Form Specifications (Teachers Module)
- F1 — Create Teacher (
POST /teachers) - F2 — Update Teacher (
PATCH /teachers/:id) - F3 — Subject Assignment (
POST /subject-assignments)
Field-by-field specs. Server column = exact DTO contract (
create-teacher.dto.ts,update-teacher.dto.ts,create-subject-assignment.dto.ts). Weak-validation warnings are derived facts (missing class-validator decorators).
F1 — Create Teacher (POST /teachers)
| # | Field | Type | Required | Server validation | Client input | Default |
|---|---|---|---|---|---|---|
| 1 | userId | string (ObjectId) | ✅ | @IsMongoId (create-teacher.dto.ts:6-7) | Searchable user picker (Users module) | — |
| 2 | employeeNumber | string | ✅ | @IsString (create-teacher.dto.ts:10-11) | Text, mono font, uppercase hint | — |
| 3 | departmentId | string (ObjectId) | — | @IsOptional @IsMongoId (:15-16) | AppDropdown from GET /departments | — |
| 4 | designationId | string (ObjectId) | — | @IsOptional @IsMongoId (:20-21) | AppDropdown from GET /designations | — |
| 5 | joiningDate | date string | — | @IsOptional @IsDateString (:25-26) | AppDatePicker, maxDate = today (proposed) | — |
| 6 | employmentStatus | string enum | — | @IsOptional @IsString — enum not validated (:31-33) | Segmented AppDropdown, 4 values from EmploymentStatus (teacher.schema.ts:7-12) | active |
| 7 | qualification | string | — | @IsOptional @IsString (:37-38) | Multiline AppTextField, 2 lines max (proposed) | — |
| 8 | experienceYears | number | — | no validator at all (:41-42) | Numeric AppTextField, int ≥ 0, ≤ 60 (proposed) | — |
| 9 | subjects | string[] (ObjectId[]) | — | no array/MongoId validator (:45-46) | Multi-select chips from GET /subjects | [] (schema default teacher.schema.ts:44) |
| 10 | classTeacherFor | string[] (ObjectId[]) | — | no validator (:48-52) | Multi-select chips from GET /classes | [] (schema default teacher.schema.ts:47) |
Submit payload (exact)
{ "userId": "…", "employeeNumber": "TCH001", "departmentId": "…",
"designationId": "…", "joiningDate": "2024-08-12", "employmentStatus": "active",
"qualification": "M.Sc.", "experienceYears": 6, "subjects": ["…"], "classTeacherFor": ["…"] }
Server responses
- 201→200 envelope
data= createdTeacherdoc (timestamps auto,teacher.schema.ts:14). - 409
DUPLICATE_RESOURCE: user-linked profile exists / employee number exists (teacher.service.ts:29-37) → inlineAppBanner; keep form state (no reload). - 400
VALIDATION_ERROR: field details mapped (00-shared/07 §3). - Notes:
tenantIdnever sent (from token,base.repository.ts:33-35);metadatanot part of create DTO (update only).
F2 — Update Teacher (PATCH /teachers/:id)
| # | Field | Notes |
|---|---|---|
| 1–9 | same as F1 minus userId | update-teacher.dto.ts:4-49; userId immutable |
| 10 | metadata | Record<string, unknown> optional (update-teacher.dto.ts:51-53); client sends {key: primitive} rows only (proposed) |
- Partial semantics:
$setmerge (teacher.service.ts:82) — omitted fields untouched; client sends only changed fields. - Conflict rules on update: no server checks —
PATCHcan setemployeeNumberto an existing number with no 409 (unlike create;teacher.service.ts:80-93only checks existence of the target record). Client pre-validates against loaded roster; server-side race remains (OQ-3). - Status transitions allowed: any of 4 enum values, no workflow restriction server-side.
F3 — Subject Assignment (POST /subject-assignments)
| # | Field | Type | Required | Server validation | Client input |
|---|---|---|---|---|---|
| 1 | teacherId | string (ObjectId) | ✅ | @IsMongoId (create-subject-assignment.dto.ts:6-7) | Prefilled from teacher detail; editable in standalone mode (proposed) |
| 2 | subjectId | string (ObjectId) | ✅ | @IsMongoId (:9-10) | Searchable picker from GET /subjects |
| 3 | classId | string (ObjectId) | ✅ | @IsMongoId (:12-13) | Year-scoped picker from GET /classes |
| 4 | academicYearId | string (ObjectId) | ✅ | @IsMongoId (:15-16) | AppDropdown from GET /academic-years, default isCurrent |
Client-side rules (server gaps)
- Duplicate guard: block exact (teacher, subject, class, year) already in matrix —
no server uniqueness (
subject-assignment.schema.ts:24-25indexes are non-unique;subject-assignment.service.ts:13-17has no check) (OQ-2). - Same teacher teaching 2 subjects in one class: allowed (no cross-check with
timetable slots — timetable has its own conflict detection at
timetable.service.ts:16-30). - Submit disabled until all 4 set; double-submit guard.
Responses
- 200
data=SubjectAssignmentdoc. Errors: 400 (invalid ids), 500; no 404 path for unknown teacherId/subjectId/classId (no existence checks — client must ensure picker values are valid).
09 — User Behaviour (Teachers Module)
- 1. Global behaviours
- 2. List screen (S1)
- 3. Detail screen (S2–S4)
- 4. Create/Edit forms (S5/S6)
- 5. Assignment editor (S7)
- 6. Deactivate (S8)
- 7. Teacher self (S9/S10,
(planned)) - 8. Error copy table (module)
- 9. Behavioural edge cases (derived)
Rules of engagement: what the UI may/must not do, derived strictly from backend behaviour. Contradictions with
PLAN.md/ flow docs are flagged, not resolved by invention.
1. Global behaviours
| Rule | Source |
|---|---|
Every list is tenant-scoped automatically; client never sends tenantId | base.repository.ts:20-30,33-35 |
Every request carries Bearer JWT; 401 → single-flight refresh → sessionExpired | 00-shared/06 §3.6 |
| Deleted (soft) records are invisible to the client — 404, not a flag in payload | base.repository.ts:20-30 |
success:false never renders raw message for 5xx — generic + requestId; 4xx business text allowed | 00-shared/07 §11 |
| Server returns raw ObjectIds for refs — client joins names from catalogs; missing ref = "—" | teacher.schema.ts:22-26,44-48 |
2. List screen (S1)
- Search input debounced 300 ms (
AppSearchBar); sinceqis ignored server-side (teacher.service.ts:66-78), client filters loaded pages locally and shows "Searching across loaded page only" helper when filter active + more pages exist (OQ-3). - Filter chips re-run local filter on loaded items; server-side filters don't exist — never claim otherwise in copy.
- Pagination: infinite scroll;
meta.hasNextstops loader (pagination-query.dto.ts:41-55). - Pull-to-refresh resets page 1 + bypasses cache (00-shared/06 §3.3).
- Empty states per 04 §7.
3. Detail screen (S2–S4)
- Tabs keep state; switching year in Assignments refetches matrix (server keyed by
academicYearId—subject-assignment.service.ts:26-31). - If detail fetch 404 → user deleted concurrently → leave detail, snackbar "Teacher removed".
- Edit navigates to S6 with prefill; back from form → refresh detail.
4. Create/Edit forms (S5/S6)
- Never optimistic — create/update are server-confirmed writes (00-shared/06 §3.5).
- On 409: keep form values; show
AppBanner+ focus related field. - On 400: map
details[].field→ inline field errors; focus first invalid (09_Accessibility_Baseline.md §10). - On 5xx/network: keep form values,
AppErrorStateinline with Retry (idempotent because create duplicate → 409 guard, update → idempotent$set). - Offline: form entry blocked with
AppOfflineBanner(no offline queue for teachers, 00-shared/06 §3.7).
5. Assignment editor (S7)
- Add = server-confirmed (no optimistic row).
- Client duplicate check runs on picker selection AND on submit (race between two admins possible — server accepts both; see QA-3).
- Remove: confirm dialog (destructive) → server 200 → row removed; 404 → row already gone → remove locally + snackbar.
- Year switch in S3 discards in-progress editor (sheet closes).
6. Deactivate (S8)
- Server-confirmed, never optimistic (irreversible).
- Confirm button pending state; haptic
heavyImpact(); on 200 → back + snackbar. - No guard server-side on active assignments/timetable (OQ-5) — dialog warns: "Existing assignments and schedule entries are not modified."
7. Teacher self (S9/S10, (planned))
- Read-only; no create/edit/remove affordances for
teacherrole. - On
on_leave/inactive: banner on My Teaching; schedule still visible. - No server "me" endpoint — client resolves own teacher id via stored session mapping (see OQ-2; documented, not invented).
8. Error copy table (module)
| Code | Screen | Client message (i18n key) |
|---|---|---|
| 409 user | F1 | teachers.create.conflict.user — "A teacher profile already exists for this user." |
| 409 emp | F1 | teachers.create.conflict.employeeNumber — "Employee number already exists." |
| 404 | S2/S3/S8 | teachers.notFound — "Teacher not found or removed." |
| 404 assignment | S3 remove | assignments.notFound — "Assignment already removed." |
| 400 | F1/F2/F3 | per-field from details |
| 429 | all | errors.rateLimited — "Too many requests. Try again in a moment." |
| 5xx | all | errors.server + requestId |
9. Behavioural edge cases (derived)
POST /teacherswithuserIdalready having a profile → 409 (create path only; update cannot change userId).PATCHemployeeNumber→ no uniqueness 409 (only create checks;teacher.service.ts:32-38vs80-93) — client pre-check is best-effort.- Assignment lists keyed by
academicYearIdreturn [] when param missing (subject-assignment.service.ts:26-31) — client always sends it. - Timetable by teacher id returns entries sorted
dayOfWeek, startTime(timetable.service.ts:55-60) — client must not re-sort by date.
10 — Interaction Specification (Teachers Module)
- 1. Touch gestures (phone/tablet)
- 2. Desktop/web (mouse + keyboard)
- 3. Haptics (mobile)
- 4. Motion per screen
- 5. Micro-interactions
- 6. Frustration controls
Specializes 00-shared/08 for this module. All motion tokens from 02_Design_Tokens.md §6.
1. Touch gestures (phone/tablet)
| Gesture | Behaviour | Screen |
|---|---|---|
| Tap row | Ink ripple (m-fast) → S2 | S1 |
| Long-press row | AppMenu: Edit, Deactivate (+ selectionClick haptic) | S1 |
| Swipe left | Remove assignment (with confirm) — keep ≤ 2 actions (08 §1) | S3 rows |
| Pull down | Refresh list / tab content | S1, S3, S4 |
| Tap FAB | Scale-in (m-entrance) → S5 | S1 |
| Back gesture | Form → detail (discard confirm if dirty, (proposed)); detail → list | S5/S6 |
| Tap assignment card | No navigation (info only) — remove via trailing menu | S3 |
2. Desktop/web (mouse + keyboard)
| Input | Behaviour |
|---|---|
| Hover row | m-fast surface tint; tooltip after 500 ms (08 §2) |
| Right-click row | same AppMenu as long-press |
+ / N | New teacher (S5) on list screen (05_Global_Information_Architecture.md §7) |
Ctrl+F | Focus search bar (proposed) |
Esc | Close editor sheet / dialog / menu |
Enter | Submit focused form; Tab logical order; visible focus ring primary 2 dp (09 §6) |
Delete key | On selected row → deactivate confirm (proposed) |
3. Haptics (mobile)
| Event | Haptic |
|---|---|
| Assignment added | lightImpact() |
| Deactivate confirmed | heavyImpact() |
| Duplicate-assignment blocked | mediumImpact() |
| Long-press menu | selectionClick() |
| Pull-to-refresh done | lightImpact() |
| Rule | disabled when system haptics off; none on web/desktop (08 §3) |
4. Motion per screen
| Screen | Entrance | Element motion |
|---|---|---|
| S1 | page fade+rise m-slow; list rows stagger m-entrance 40 ms | chips animate selection m-fast; status chip badge pop |
| S2 | header card hero from list avatar (Hero m-slow) | tab indicator slide m-base; info rows fade-in staggered 40 ms |
| S3 | tab content fade m-fast | year dropdown m-base; row add/remove: AnimatedList insert/remove m-base |
| S4 | grid fade m-fast | none (data-dense) |
| S5/S6 | full-page slide m-base | field focus states m-fast; button spinner replaces label |
| S7 | sheet slide-up m-base, r-xl corners | picker option highlight m-fast |
| S8 | dialog scale+fade m-base | destructive button press ripple m-fast |
| S9/S10 | page fade+rise | summary tiles entrance stagger 40 ms |
Reduced-motion: all above collapse to fades ≤ m-instant, no stagger, no hero
(08 §4, 09 §8).
5. Micro-interactions
- FAB hide/show on scroll (S1): hide on scroll-down, show on scroll-up (03 §D).
- Submit button: label → spinner; on success brief check + snackbar (08 §5); disabled while pending (anti-double-submit 08 §6).
- Year switch in S3: dropdown + matrix cross-fade
m-fast; stale request dropped (request generation token, 13 §4). - Infinite scroll: bottom spinner fade-in
m-instant; "End of list" footer bodySmall (03 §F). - Status chip change after PATCH:
AnimatedSwitchericon morphm-fast.
6. Frustration controls
- Every interactive element responds ≤ 100 ms (08 §6).
- Network-bound actions always pending-visible — never silent.
- Form save double-tap prevented; deactivate dialog requires explicit confirm (typed confirm of employeeNumber NOT required — soft-delete is reversible server-side; 05_Global_Information_Architecture.md §5 rule applies only to irreversible purge).
11 — Design System Mapping (Teachers Module)
- 1. Screen → M3 component map
- 2. Status color semantics (module canon)
- 3. Typography
- 4. Spacing & layout tokens
- 5. Elevation
- 6. Icons (Material Symbols outlined, filled for status — 02 §7)
- 7. Module-level theming rules
Extends 00-shared/04 with module components. Every widget → token → Flutter mapping.
1. Screen → M3 component map
| Screen | Primary widgets | M3 roles |
|---|---|---|
| S1 list | AppListTile, AppSearchBar, FilterChip, FAB | surface, primary, secondaryContainer chips |
| S1 cards (tablet) | AppCard (e-1) | surfaceContainerLow |
| S2 profile | AppCard tonal, AppInfoRow, AppTabs, AppBadge | primaryContainer header, secondaryContainer status |
| S3 assignments | AppDropdown, AssignmentCard, AppEmptyState | surfaceContainerLow, outline |
| S4 schedule | grid cards, AppSectionHeader | surfaceContainerLow, e-1 |
| S5/S6 forms | TextFormField (filled), AppDropdown, FilterChip multi, AppDatePicker, FilledButton | fill surfaceVariant, focus primary, error error |
| S7 editor | AppBottomSheet + pickers, FilledButton | surfaceContainerHigh, e-5 |
| S8 deactivate | AlertDialog, destructive FilledButton | surfaceContainerHigh, error/onError |
| S9/S10 self | AppStatTile, AppBanner, grid | secondaryContainer banner |
2. Status color semantics (module canon)
| EmploymentStatus | Token pair | Rationale |
|---|---|---|
active | successContainer/onSuccessContainer | success role (02 §1.1) |
inactive | surfaceVariant/onSurfaceVariant | neutral |
on_leave | warningContainer/onWarningContainer | warning role |
terminated | errorContainer/onErrorContainer | error role |
Always paired with icon + label (09 §9 color-blind rule).
3. Typography
| Use | Token | Widget |
|---|---|---|
| Screen title | headlineMedium | S1 AppBar |
| Teacher name (header) | titleLarge | S2 |
| Row titles / card titles | titleMedium | S1 rows, S3 cards |
| Employee number | mono (FontFeature.tabularFigures, 02 §2) | S1 subtitle, S2 |
| Metadata / timestamps | bodySmall | S2, S6 |
| Buttons, chips, tabs | labelLarge | everywhere |
| Status text | labelMedium | StatusChip |
4. Spacing & layout tokens
| Token | Value | Use |
|---|---|---|
| Screen padding | 16 / 24 (tablet+) (04 §6) | all screens |
| Card padding | 16 | S2 header, S3 cards |
| Section gap | 24 | S2 sections |
| Field gap | 16 | S5/S6 forms |
| List row | 56 | S1 |
| Tab bar | standard M3 | S2 |
| Dialog corner | r-lg 12 | S8 |
| Sheet corner | r-xl 16 top | S7 |
5. Elevation
| Component | Token |
|---|---|
| List rows / flat cards | e-0 / e-1 |
| S2 header card | e-1 |
| FAB | e-3 |
| S8 dialog | e-4 |
| S7 sheet | e-5 |
6. Icons (Material Symbols outlined, filled for status — 02 §7)
| Icon | Use |
|---|---|
person_add | FAB "Add teacher" |
groups | list empty state |
menu_book | assignments empty state |
edit, person_off | row menu |
delete | deactivate |
check_circle / pause_circle / beach_access / person_off | StatusChip states |
filter_list | filter chips toggle |
search | search |
All icons have tooltip or Semantics.label (09 §7).
7. Module-level theming rules
- Status colors ONLY via
StatusChip(single source of truth). - Employee numbers always
mono— consistent identity scanning. - Destructive actions always
errortoken — never custom red. - Assignment "added" confirmation =
secondaryContainerbanner; duplicates =errorContainer.
12 — API Mapping (Teachers Module)
- 0. Module-wide request envelope & client policy
- 1. Teachers CRUD — module core
- 2. Subject assignments — teaching matrix (S3/S7)
- 3. Supporting catalogs (read-only for this module)
- 4. Loading / streaming / realtime
- 5. Client error mapping table (module)
- 6. Pagination summary
- 7. Optimistic / undo
Exact wire contract per 00-shared/07. Base
/api/v1; envelope{success,message,data,meta?,timestamp,requestId}. Global guards:RateLimitGuard → JwtAuthGuard → RbacGuard(app.module.ts:129-131); no teachers endpoint carries RBAC metadata (rbac.guard.ts:29→ JWT-only).BearerJWT;tenantIdfrom token only (base.repository.ts:33-35).
0. Module-wide request envelope & client policy
| Aspect | Contract |
|---|---|
| Headers | Authorization: Bearer; x-request-id client UUID; Content-Type: application/json |
| Tenancy | never in body; server injects tenantId + isDeleted:false scope (base.repository.ts:20-30) |
| Caching | reference catalogs (departments, designations, subjects, classes, years) 24 h TTL; teacher lists 5 min TTL (00-shared/06 §3.3) |
| Offline | reads last-good cache + banner; writes blocked (no offline queue for this module) |
| Retry | backoff on 5xx/network; no auto-retry on 429 |
| Idempotency | create is retry-safe via 409 duplicate guards (teacher.service.ts:27-38); Idempotency-Key optional (B6 shared ledger) |
1. Teachers CRUD — module core
GET /teachers — list (S1)
- Query:
page(1-based, default 1),limit(1–100, default 20),sort(-field),q(pagination-query.dto.ts:5-30). - Derived caveat:
TeacherService.findignoressortandq(teacher.service.ts:66-78); no status/department filters exist. - Success:
data: Teacher[],meta: {page,limit,totalItems,totalPages,hasNext,hasPrevious}(buildPaginationMeta,pagination-query.dto.ts:41-55). - Errors: 400 (bad page/limit), 401, 429, 5xx.
GET /teachers/:id — detail (S2)
- Success:
data: Teacher(raw schema doc, refs as ObjectIds). - 404
RESOURCE_NOT_FOUND"Teacher not found." (teacher.service.ts:54-58).
POST /teachers — create (S5)
- Body:
CreateTeacherDto(create-teacher.dto.ts:4-52, F1). - 201→200
data: Teacher. 409DUPLICATE_RESOURCE(user profile exists,teacher.service.ts:27-31; employeeNumber exists,:32-38). 400 validation. - Side-effects:
TeacherCreated→in-appjobteacher-created(event-queue-map.ts:31) → notification (inapp.worker.ts:46-53); search index (search-indexer.service.ts:10).
PATCH /teachers/:id — update (S6)
- Body:
UpdateTeacherDto(update-teacher.dto.ts:4-53, F2);$setmerge (teacher.service.ts:80-93);userIdimmutable. - 200
data: Teacher; 404 (missing or soft-deleted); 400. - Side-effects:
TeacherUpdated→audit-writelog-teacher-updated(event-queue-map.ts:32); search re-index (search-indexer.service.ts:17).
DELETE /teachers/:id — deactivate (S8)
- 200
dataabsent (void); soft-deleteisDeleted/deletedAt/deletedBy+version+1(base.repository.ts:68-74). - 404 "Teacher not found." (
teacher.service.ts:95-97). - Side-effects:
TeacherDeleted→audit-writelog-teacher-deleted(event-queue-map.ts:33); search index removal (search-indexer.service.ts:24,52-59).
2. Subject assignments — teaching matrix (S3/S7)
POST /subject-assignments — create (S7)
- Body:
CreateSubjectAssignmentDto(create-subject-assignment.dto.ts:4-19, F3). - 200
data: SubjectAssignment. 400 validation. No 404/409 paths — no existence or duplicate checks (subject-assignment.service.ts:13-17) (OQ-2).
GET /subject-assignments/by-teacher/:teacherId?academicYearId= (S3)
academicYearIdrequired in practice (missing →[]since filter includes it,subject-assignment.service.ts:26-31).- Success:
data: SubjectAssignment[]— non-paginated array, nometa. - Sort: insertion order (no index sort).
GET /subject-assignments/by-class/:classId?academicYearId= (coordinator view)
- Same shape; filter
{classId, academicYearId}(subject-assignment.service.ts:19-24).
DELETE /subject-assignments/:id — remove
- Soft-delete (
subject-assignment.service.ts:33-36); 404 "Assignment not found.". - Caveat:
subject_assignmentshas notimestamps: true(subject-assignment.schema.ts:7) andisDeletedscoping comes fromBaseSchema.
3. Supporting catalogs (read-only for this module)
| Endpoint | Used by | Source |
|---|---|---|
GET /departments (paginated) | F1/F2 department picker, S1 filter | department.controller.ts:29-31 |
GET /designations (paginated) | F1/F2 designation picker | designation.controller.ts |
GET /subjects (paginated) | F1 subjects multi, S7 subject picker | subject.controller.ts:27-29 |
GET /classes (+ by-year/:academicYearId) | F1 classTeacherFor multi, S7 class picker | class.controller.ts:27-33 |
GET /academic-years | S3/S7 year picker (isCurrent flag) | academic-year.controller.ts:27-30 |
GET /timetable?teacherId= | S4 schedule | timetable.controller.ts:21-27 |
GET /dashboard/overview | teachers.total KPI | dashboard.service.ts:32,52 |
GET /users | F5 user picker | Users module |
4. Loading / streaming / realtime
| Screen | Loading | Realtime |
|---|---|---|
| S1 list | skeleton rows | — (teacher.created WS event (planned)) |
| S2/S3/S4 detail | skeleton card / rows | refresh on notification.new for own tenant (planned) |
| S5/S6/S7 forms | submit spinner only | — |
| S8 dialog | button spinner | — |
5. Client error mapping table (module)
| Screen | Code | UI |
|---|---|---|
| S1/S2/S3 | 401 | silent refresh; fail → sessionExpired |
| S1/S2/S3 | 403 | 403 screen (future server enforcement) |
| S2/S3/S8 | 404 | "Teacher not found or removed" → back |
| S3 remove | 404 | treat as removed locally |
| S5 | 409 | inline conflict banner, keep form |
| S5/S6/S7 | 400 | per-field errors |
| all | 429 | "Try again in a moment", no auto-retry |
| all | 5xx | generic + requestId + retry |
6. Pagination summary
GET /teachers: paginated (page/limit/meta) — infinite scroll.GET /subject-assignments/by-*,GET /timetable, catalogs: non-paginated arrays except catalogs which paginate viaPaginationQueryDto— client treats catalogs as page-through (limit 100) or caches 24 h.GET /departments//designationspaginate — client loads withlimit=100and caches.
7. Optimistic / undo
- List refresh + detail refresh: server-first (read-after-write on navigation back).
- Status change (F2) is a
$set— safe; client applies after 200 only (no rollback complexity). - No destructive op is optimistic (00-shared/06 §3.5); UNDO snackbar not offered for deactivate (irreversible by design) — offering undo would be inventing an API surface (no reactivation endpoint).
13 — State Management (Teachers Module)
- 1. Cubits
- 2. State classes
- 3. Repository & cache keys
- 4. Events
- 5. Realtime / cross-cubit
- 6. Optimistic policy (module)
- 7. Error handling
- 8. Testing hooks
Extends 00-shared/06. Bloc/Cubit, one cubit per screen; repository layer only place touching HTTP; typed
ApiException(code, status, fieldDetails).
1. Cubits
| Cubit | Screen | State | Notes |
|---|---|---|---|
TeachersListCubit | S1 | TeachersListState | paginated mixin + filters |
TeacherDetailCubit | S2–S4 | TeacherDetailState | tabs, year selection |
TeacherFormCubit | S5/S6 | TeacherFormState | create/update + dirty tracking |
AssignmentEditorCubit | S7 | AssignmentEditorState | pickers + duplicate guard |
AssignmentsTabCubit | S3 | AssignmentsTabState | year-keyed matrix |
ScheduleTabCubit | S4 | ScheduleTabState | week grid |
MyTeachingCubit | S9/S10 (planned) | MyTeachingState | composed self-view |
2. State classes
// S1
class TeachersListState {
LoadState load; // Initial | Loading | Success | Error
List<Teacher> items; // merged pages
int page; bool hasNext;
List<EmploymentStatus> statusFilter;
String? departmentId, designationId, query; // client-side filters (OQ-3)
}
// S2–S4
class TeacherDetailState {
LoadState load;
Teacher? teacher;
int tabIndex; // 0 profile, 1 assignments, 2 schedule
AcademicYear currentYear; // default isCurrent
List<SubjectAssignment>? assignments;
List<TimetableEntry>? timetable;
int yearRequestToken; // drop stale year-switch responses
}
// S5/S6
class TeacherFormState {
LoadState submit; // idle | submitting | success | error
TeacherFormData data; // field DTO mirror (F1/F2)
Map<String, String> fieldErrors; // from 400 details
ConflictType? conflict; // user | employeeNumber (409)
bool dirty;
}
// S7
class AssignmentEditorState {
LoadState submit;
String? subjectId, classId, academicYearId;
bool duplicateDetected; // client-side (server gap, OQ-2)
}
3. Repository & cache keys
class TeacherRepository {
Future<Page<Teacher>> list({page, limit}); // GET /teachers
Future<Teacher> byId(String id); // GET /teachers/:id
Future<Teacher> create(TeacherDraft dto); // POST /teachers
Future<Teacher> update(String id, TeacherPatch dto);// PATCH /teachers/:id
Future<void> remove(String id); // DELETE /teachers/:id
Future<List<SubjectAssignment>> assignmentsByTeacher(String teacherId, String yearId);
Future<SubjectAssignment> createAssignment(CreateSubjectAssignmentDto dto);
Future<void> removeAssignment(String id);
}
| Cache key | TTL | Policy |
|---|---|---|
sl:{tenant}:teachers:page:{n} | 5 min | stale-while-revalidate (06 §3.3) |
sl:{tenant}:teachers:refs:{depts|designations|subjects|classes|years} | 24 h | reference data |
sl:{tenant}:teachers:assignments:{teacherId}:{year} | 5 min | volatile matrix |
detail teachers:{id} | none | server-fresh on every visit |
RefreshIndicator always bypasses cache (06 §3.3).
4. Events
LoadTeachers,LoadMore,RefreshTeachers,ChangeStatusFilter(dept, designation),Search(q),ClearFiltersLoadTeacher(id),ChangeTab(i),ChangeYear(yearId),RefreshTabSubmitCreate(draft),SubmitUpdate(patch),DiscardSelectSubject/Class/Year,AddAssignment,RemoveAssignment(id)- Naming per
00-shared/06 §4.
5. Realtime / cross-cubit
notification.new(WS,00-shared/07 §8) for own tenant → teachers list marks refresh-needed badge(planned); no live row mutation (soft-delete is 404-driven).- Dashboard
teachers.totalrefreshes onTeacherCreated/Deletedevents when dashboard visible (viaDashboardCubitrefresh —dashboard.service.ts:32).
6. Optimistic policy (module)
| Action | Optimistic? |
|---|---|
| List filters, search, year switch | yes (local state) |
| Assignment add/remove | no — server-confirmed (no server dedup; confirm before UI change) |
| Teacher create/update | no |
| Deactivate | no (irreversible) |
| Pull-to-refresh | cache-bypass reload |
7. Error handling
- 409 conflict → form-level state (
ConflictType), never clears fields. - 400 →
fieldErrorsfromerror.details(00-shared/07 §3). - 404 → detail/list treat as removed: navigate back with snackbar.
- 401 →
AuthCubitrefresh; fail → session expiry. - 5xx →
AppErrorStatewith requestId; retry re-emits Load event.
8. Testing hooks
TeachersListCubitpure-Dart unit tests (mockTeacherRepository): pagination merge, filter application, duplicate detection, conflict mapping.- Widget tests: S1 3-state (loading/error/empty), S5 409-banner, S7 duplicate-block.
AppStateObservertransition logs dev-only (06 §6).
14 — QA Checklist (Teachers Module)
- 1. Assignment conflict & duplicate matrix
- 2. Duplicate teacher rules
- 3. Deactivate rules
- 4. Pagination & list integrity
- 5. Tenant isolation & security
- 6. Event/side-effect correctness [backend]
- 7. Client QA (per 00-shared/10)
- 8. Release gates (module)
Module-specific additions to 00-shared/10. Items marked [backend] are bugs or gaps derivable from source and should be raised with the API team, not papered over in the UI.
1. Assignment conflict & duplicate matrix
| # | Scenario | Expected | Server reality |
|---|---|---|---|
| QA-1 | Same (teacher, subject, class, year) assigned twice | Client blocks + inline error | No server guard (subject-assignment.service.ts:13-17; non-unique indexes subject-assignment.schema.ts:24-25) — [backend] add unique index or 409 |
| QA-2 | Same teacher, same subject, two classes (same year) | Allowed (two rows) | Allowed — expected |
| QA-3 | Two admins assign same triple concurrently | Client can't prevent (server race) | Both succeed — [backend]; client shows both rows, dedup display |
| QA-4 | Assign teacher to subject not in class curriculum | Client warns (no server model of curriculum) | No check — accept (no curriculum entity) |
| QA-5 | Timetable conflict for teacher (two periods same time) | Handled by Timetable module, not this one | timetable.service.ts:16-30 409 — cross-module QA |
| QA-6 | by-teacher / by-class without academicYearId | Client never sends; if API called bare → expect [] | Returns [] (subject-assignment.service.ts:26-31) — verify client picker always set |
2. Duplicate teacher rules
| # | Scenario | Expected |
|---|---|---|
| QA-7 | POST /teachers with userId already profiled | 409 DUPLICATE_RESOURCE (teacher.service.ts:27-31) — UI banner |
| QA-8 | POST /teachers with existing employeeNumber | 409 (teacher.service.ts:32-38) — banner |
| QA-9 | PATCH to existing employeeNumber | No 409 (teacher.service.ts:80-93 only checks record exists) — [backend]; client pre-check + accept race |
| QA-10 | userId change via PATCH | Blocked by DTO absence (update-teacher.dto.ts) — verify immutability in UI |
3. Deactivate rules
| # | Scenario | Expected |
|---|---|---|
| QA-11 | DELETE /teachers/:id | Soft-delete; all subsequent list/detail queries exclude (base.repository.ts:20-30,68-74) |
| QA-12 | Deactivate teacher with active assignments/timetable | Allowed server-side (no guard) — dialog warns; [backend] consider BUSINESS_RULE_VIOLATION guard (OQ-5) |
| QA-13 | Re-activation | No endpoint — not offered in UI |
| QA-14 | Deactivate already-deleted id | 404 → snackbar "already removed" → back |
| QA-15 | Audit trail | TeacherDeleted → audit-write job (event-queue-map.ts:33) — verify audit UI shows it |
4. Pagination & list integrity
| # | Scenario | Expected |
|---|---|---|
| QA-16 | page=1,limit=20 → meta math | totalPages = max(1, ceil(total/limit)), hasNext/hasPrevious (pagination-query.dto.ts:41-55) |
| QA-17 | limit > 100 | 400 VALIDATION_ERROR (pagination-query.dto.ts:13-19) — client clamps |
| QA-18 | sort/q params | Ignored by service (teacher.service.ts:66-78) — UI must not claim server search; local filter only; [backend] (OQ-3) |
| QA-19 | Soft-deleted rows in middle page | Never returned (scope) — no ghost rows |
| QA-20 | Page race on pull-to-refresh + load-more | Request generation token; no duplicate items |
| QA-21 | Subject-assignments/by-* non-paginated | Full array; verify memory on large schools; server pagination (planned) |
5. Tenant isolation & security
| # | Scenario | Expected |
|---|---|---|
| QA-22 | Cross-tenant GET /teachers/:id | 404 (scope tenantId injected, base.repository.ts:20-30) — never leak existence (00-shared/07 §3) |
| QA-23 | No tenantId in bodies | Client never sends it (07 §6) |
| QA-24 | Unauthenticated call | 401 → refresh flow |
6. Event/side-effect correctness [backend]
| # | Item | Evidence |
|---|---|---|
| QA-25 | TeacherCreated routed to in-app, not emails — PLAN.md:36 promises welcome email | event-queue-map.ts:31 vs PLAN.md:36 — confirm email intent |
| QA-26 | In-app notification type='TeacherCreated' vs NotificationType enum (email_verified|password_reset|verification_resent|welcome) | inapp.worker.ts:46-53 + notification.schema.ts:7-12 — likely enum validation failure → job retries → DLQ |
| QA-27 | Search index title for TeacherCreated payload {teacherId, employeeNumber} — no name → not indexed | search-indexer.service.ts:62-88 + teacher.service.ts:47-49 |
7. Client QA (per 00-shared/10)
| # | Item |
|---|---|
| QA-28 | A11y: status chips label+icon (color-blind), focus order S1→S2, dialog focus trap |
| QA-29 | Text scale 2×: long designation names + status chips don't clip |
| QA-30 | Dark mode parity: all status token pairs verified |
| QA-31 | Offline: list from cache + banner; forms blocked |
| QA-32 | Perf: S1 skeleton ≤ 200 ms; no duplicate parallel requests (10 §1) |
| QA-33 | Analytics (proposed): teachers.list.search, teachers.create.submit, teachers.assignments.add, teachers.deactivate.confirm (10 §8 naming) |
| QA-34 | Golden tests: StatusChip ×4 ×2 modes, TeacherListTile, AssignmentCard, TeacherCard ×3 sizes |
8. Release gates (module)
- Server gaps QA-1/9/12/18/25/26/27 triaged with API team (none block v1 UI if client compensates: dedup, pre-check, warning copy)
- All envelope error codes map to i18n keys (no raw 5xx text)
- E2E: create → assign → edit → deactivate journey on device cloud
- Cross-tenant 404 verified in E2E
15 — Flutter Implementation Guide (Teachers Module)
- 1. Module folder
- 2. Domain models
- 3. Repository
- 4. Cubits
- 5. Router
- 6. Theme & components
- 7. i18n keys
- 8. Tests
- 9. Known backend gaps to coordinate (from 14-QA)
Extends 00-shared/11. Forward-looking: no client repo exists (shared ledger A1). Everything below derives from
src/modules/teachers/**,src/modules/academics/**(subject-assignments),src/modules/staff/**(catalogs),src/modules/timetable/**.
1. Module folder
lib/features/teachers/
├── data/
│ ├── dto/teacher_dto.dart # envelope payload mapper
│ ├── dto/subject_assignment_dto.dart
│ ├── dto/teacher_draft_dto.dart # create/update payloads (F1/F2)
│ ├── models/teacher.dart # + EmploymentStatus enum
│ ├── models/subject_assignment.dart
│ └── repositories/teacher_repository.dart
├── domain/
│ └── teachers_service.dart # optional: catalog-join helper
└── presentation/
├── cubit/teachers_list_cubit.dart
├── cubit/teacher_detail_cubit.dart
├── cubit/teacher_form_cubit.dart
├── cubit/assignment_editor_cubit.dart
├── pages/teachers_list_page.dart
├── pages/teacher_detail_page.dart
├── pages/teacher_form_page.dart
└── widgets/status_chip.dart, teacher_list_tile.dart,
teacher_card.dart, assignment_card.dart,
assignment_matrix_header.dart, subject_badge.dart
2. Domain models
enum EmploymentStatus { active, inactive, onLeave, terminated }
class Teacher {
final String id;
final String userId; // identity link (teacher.schema.ts:16-17)
final String employeeNumber; // mono display
final String? departmentId, designationId;
final DateTime? joiningDate;
final EmploymentStatus employmentStatus;
final String? qualification;
final int experienceYears; // default 0 (teacher.schema.ts:41-42)
final List<String> subjects; // ObjectIds (teacher.schema.ts:44-45)
final List<String> classTeacherFor;
final Map<String, dynamic>? metadata;
}
class SubjectAssignment {
final String id, teacherId, subjectId, classId, academicYearId;
}
- DTO→model:
fromJsonwith strict types; enums viaEnumByNamewith unknown →activefallback(proposed)(server enum not validated on input, but stored values are enum-safe,teacher.schema.ts:31-36). - Ref-name resolution:
TeachersService.joinNames(teacher, catalogs)— catalogs (departments/designations/subjects/classes/years) loaded once, cached 24 h (13 §3).
3. Repository
class TeacherRepository {
TeacherRepository(this._dio); // AppDio (00-shared/11 §5)
Future<Page<Teacher>> list({int page = 1, int limit = 20});
Future<Teacher> byId(String id);
Future<Teacher> create(TeacherDraftDto dto); // POST /teachers
Future<Teacher> update(String id, TeacherPatchDto dto);// PATCH /teachers/:id
Future<void> remove(String id); // DELETE /teachers/:id
Future<List<SubjectAssignment>> assignmentsByTeacher(String teacherId, String academicYearId);
Future<SubjectAssignment> createAssignment(Map<String, String> triple); // POST /subject-assignments
Future<void> removeAssignment(String id); // DELETE /subject-assignments/:id
}
- Errors: interceptor maps envelope →
ApiException(code, status, fieldDetails)(00-shared/11 §5); 409 exposesconflictflavor for banner copy. - Pagination:
Page<T>helper (items +meta) shared with other modules.
4. Cubits
TeachersListCubit—PaginatedListMixin<Teacher>(00-shared/06 §3.2); local filter application for status/dept/designation/q (server ignoressort/q,teacher.service.ts:66-78);RefreshTeachersbypasses cache.TeacherDetailCubit— parallel loads: profile + assignments + timetable (three repos) with per-tabLoadState;ChangeYearemits token, drops stale responses.TeacherFormCubit— mirrorsTeacherDraftDto/TeacherPatchDto; on 400 mapsfieldDetails; on 409 setsConflictType;dirtyguards back-navigation.AssignmentEditorCubit— duplicate check against current matrix (13 §2state); submit disabled while pending.
5. Router
// go_router additions (00-shared/05 §4 + 00-shared/11 §6)
GoRoute(path: '/staff/teachers', builder: TeachersListPage.new),
GoRoute(path: '/staff/teachers/new', builder: TeacherFormPage(mode: create)),
GoRoute(path: '/staff/teachers/:id', builder: TeacherDetailPage.new),
GoRoute(path: '/staff/teachers/:id/edit', builder: TeacherFormPage(mode: edit)),
// (planned) teacher self
GoRoute(path: '/my/teaching', builder: MyTeachingPage.new),
- Guards:
permissionGuard('staff.read')client-side mirror ofpermissions.constants.ts:19(server is JWT-only today —01 §5, OQ-1). - Deep link:
studylyon://teachers/:id.
6. Theme & components
- All tokens via
AppTheme(00-shared/04); module components in07; status colors only insideStatusChip(11 §7). employeeNumberrendered withmono+tabularFigures(02 §2).
7. i18n keys
teachers.title, teachers.search.hint, teachers.add,
teachers.status.{active,inactive,on_leave,terminated},
teachers.create.conflict.user, teachers.create.conflict.employeeNumber,
teachers.notFound, teachers.deactivate.title, teachers.deactivate.body,
teachers.deactivate.done, teachers.empty.title, teachers.empty.filtered,
assignments.title, assignments.add, assignments.duplicate,
assignments.remove.confirm, assignments.notFound, assignments.empty.{teacher,year},
schedule.empty, teachers.self.banner.onLeave, errors.server, errors.rateLimited
8. Tests
| Layer | Cases |
|---|---|
| Unit | TeachersListCubit pagination merge + local filters; TeacherFormCubit 400/409 mapping; AssignmentEditorCubit duplicate guard; DTO↔model mappers |
| Widget | S1 3-state (skeleton/error/empty), S5 409 banner, S7 duplicate block, StatusChip golden ×4×2 |
| Integration | create→assign→edit→deactivate journey; 404 detail handling |
| E2E | P0: full teacher lifecycle + cross-tenant 404 (per 00-shared/10 §9) |
9. Known backend gaps to coordinate (from 14-QA)
- Duplicate-assignment guard (QA-1), PATCH employeeNumber 409 (QA-9), deactivate guard (QA-12), server search/sort (QA-18)
-
TeacherCreatedemail intent + in-app notification enum mismatch (QA-25/26) - Search-index title for teacher events (QA-27)
-
GET /teachers/mefor teacher self-view (OQ-2)
01 — Product Overview (Timetable Module)
- 1. Purpose
- 2. Module boundary
- 3. Entry lifecycle (end-to-end)
- 4. Conflict detection — the one real business rule
- 5. Permissions reality
- 6. Events & side-effects
- 7. Dependencies
- 8. Business goals
- 9. Success metrics
- 10. Edge cases (derived)
- 11. Assumptions (module)
- 12. Open questions (module-level)
StudyLyon — multi-tenant ERP / School Management API. This package designs the Timetable module client (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, events, and business rules are derived directly from
src/modules/timetable/**,src/modules/academics/**(class/subject references),src/modules/teachers/**,src/modules/rooms/**,src/modules/rbac/**, anddocs/IMPLEMENTATION_PLAN.md. No feature is invented; gaps are flagged and marked(planned)/(proposed)/(forward-looking).
1. Purpose
Timetable is the scheduling domain: the weekly grid of teaching slots — one
timetable_entries document per (class, subject, teacher, day, time range), optionally
pinned to a room, all scoped to one academic year. It answers "what happens where and
with whom, on which day and at what time" for classes, teachers, and rooms.
| Responsibility | Source |
|---|---|
| Create a timetable entry (only write op implemented) | timetable.controller.ts:14-18 |
| Conflict detection: teacher double-booking + room overlap on the same day | timetable.service.ts:16-30 |
Read timetable by class — sorted dayOfWeek, startTime | timetable.service.ts:48-53 |
Read timetable by teacher — sorted dayOfWeek, startTime | timetable.service.ts:55-60 |
| Six-day week model (Monday–Saturday; no Sunday) | timetable.schema.ts:7-14 |
Time range stored as zero-padded HH:MM strings (lexical compare) | timetable.schema.ts:33-37; timetable.service.ts:62-69 |
| Tenant isolation + soft-delete scope on every query | base.repository.ts:20-30 (via TimetableRepository) |
| Domain event on create | timetable.service.ts:33-44 (TimetableEntryCreated) |
| Weekly-grid feature flag (school + coaching) | IMPLEMENTATION_PLAN.md:327-328 |
2. Module boundary
| Aspect | Timetable | Neighbouring module |
|---|---|---|
| Owns | timetable_entries docs (day/time slot semantics, conflict rule) | — |
| References | Class, Subject, Teacher, Room, AcademicYear as ObjectIds (raw refs, no population server-side) | timetable.schema.ts:18-40 |
| Does not own | classes, subjects, teachers, rooms, academic years — all read from their modules' catalogs | class.schema.ts, subject.schema.ts, teacher.schema.ts, room.schema.ts |
| Today | create + two read paths | timetable.controller.ts:10 |
| Roadmap | bulk create, conflict UI, substitution, workload, export | IMPLEMENTATION_PLAN.md:226 |
A Class may carry its own default roomId string (class.schema.ts:30-31) — a
separate field from the timetable entry's roomId ObjectId; the client must not
treat them as the same source of truth.
3. Entry lifecycle (end-to-end)
Catalog ready (classes, subjects, teachers, rooms, academic years exist)
→ Entry created POST /timetable [conflict check → 409]
→ TimetableEntryCreated event emitted (no queue routing found — gap, see §6)
→ Read back GET /timetable?classId= | GET /timetable?teacherId=
→ Edit / Delete — **no endpoints implemented** (planned)
Deletion semantics today: none — there is no DELETE /timetable/:id. The schema
extends BaseSchema (soft-delete columns exist) but no route, service method, or
repository call uses them for timetable. Any "remove slot" UI is (planned).
4. Conflict detection — the one real business rule
TimetableService.create loads existing entries for the same day where
teacherId or roomId matches the new entry (timetable.service.ts:17-22), then
throws ConflictException('Schedule conflict detected') if any of them overlaps in
time (timetable.service.ts:24-30, 62-69):
| Check | Covered? | Evidence |
|---|---|---|
| Same teacher, same day, overlapping time | ✅ 409 | $or clause {teacherId, dayOfWeek} (timetable.service.ts:19) |
| Same room, same day, overlapping time | ✅ 409 (only when roomId set) | {roomId, dayOfWeek} (timetable.service.ts:20) |
| Same class, same day, overlapping time | ❌ not checked — two entries for one class may overlap | query has no classId clause (timetable.service.ts:17-22) |
Boundary overlap (end == start of another entry) | ✅ no overlap (start1 < end2 && start2 < end1, timetable.service.ts:68) | back-to-back periods are legal |
| Overlap with other day | ❌ no check (correct by design — weekly model) | day filter only |
When roomId is absent from the DTO, Mongoose strips undefined from the query, so
the roomId clause is a no-op — effectively teacher-only conflict detection for
room-less entries.
5. Permissions reality
timetable.readandtimetable.createexist in the permission catalog (permissions.constants.ts:44-45).TimetableControlleris guarded only by@UseGuards(JwtAuthGuard)(timetable.controller.ts:9); no@Roles/@Permissionsmetadata is applied, so the globalRbacGuardpasses any authenticated request (rbac.guard.ts:29).- Therefore the client must enforce
timetable.read/timetable.createin UI/routing itself (mirror guard), and should not assume server-side 403s.
6. Events & side-effects
| Event | Emitted at | Queue routing | Side-effect |
|---|---|---|---|
TimetableEntryCreated | timetable.service.ts:33-44 (payload {entryId, classId, teacherId}) | none found — grep of event-queue-map.ts shows no mapping | nothing wired; notifications/audit/search (planned) |
Gap: the event is emitted but never routed; subscribers (e.g. teacher notification,
audit trail) are (planned).
7. Dependencies
| Dependency | Role | Source |
|---|---|---|
Academics — Class | classId target of a slot | class.schema.ts:8-35 (name, gradeId, sectionId, capacity, classTeacherId) |
Academics — Subject | subjectId of a slot | subject.schema.ts:8-35 (code, name, shortName) |
Academics — AcademicYear | academicYearId scoping | timetable.schema.ts:39-40 |
Teachers — Teacher | teacherId of a slot | teacher.schema.ts:15-52 |
Rooms — Room | optional roomId of a slot | room.schema.ts:16-35; rooms.controller.ts:20 |
| RBAC | timetable.read / timetable.create perms (client-side gate only) | permissions.constants.ts:44-45 |
| Event bus | TimetableEntryCreated emission | timetable.service.ts:33 |
8. Business goals
| Goal | Measure |
|---|---|
| Build a weekly grid in < 2 min | ≤ N create calls, one per slot; conflict feedback ≤ 1 round trip |
| No teacher double-booking | 409 Schedule conflict detected (timetable.service.ts:28) |
| No room overlap | 409 when roomId set and overlapping |
| Tenant isolation structural | every repo call injects tenantId + isDeleted:false (base.repository.ts:20-30) |
| Sorting contract stable | dayOfWeek, startTime ascending on both read paths (timetable.service.ts:51,58) |
9. Success metrics
- Grid renders from a single
GET /timetable?classId=response, server-sorted (timetable.service.ts:48-53) — client never re-sorts. - Entry create with conflict → inline
AppBannerin the editor, form values kept. - Grid cells render ≥ 40 slots without jank on low-end devices (see
15 §9). - Zero
tenantIdleaks in bodies; cross-tenant reads are 404s (base.repository.ts:20-30).
10. Edge cases (derived)
GET /timetablewith neitherclassIdnorteacherId→[](timetable.controller.ts:26-28).- Both params present →
classIdwins (timetable.controller.ts:26-27). - Conflict on create → 409
ConflictException— no error code in body beyond the default NestJS shape; client matches on 409 status + message. - Times are plain strings (
@IsString,create-timetable-entry.dto.ts:28-33): overlap math is lexical string comparison (timetable.service.ts:62-69) — valid only while times are zero-paddedHH:MM; client must enforce the format (server does not). dayOfWeekvalues:monday–saturdayonly (timetable.schema.ts:7-14);sundayis rejected by@IsEnum(create-timetable-entry.dto.ts:23-25).
11. Assumptions (module)
- Client is forward-looking: backend implements only create + two reads; the full client (grid UX, editors, room view) is specced here per user instruction. The PRD lists native mobile as a later-phase, read-only companion; this package specs a full client (read + write) anyway — the two mandates coexist; write surfaces target admin/coordinator roles.
- Update/delete/bulk-import/substitution/workload/export are not implemented
(
IMPLEMENTATION_PLAN.md:226marks "Bulk create, conflicts, substitution, workload, export" as a planned phase-4 row) → all such surfaces are(planned). - Room view exists only as a client composition (fetch per-class grids and merge);
a dedicated
GET /timetable?roomId=is(planned). - No Sunday teaching;
saturdayis a legal teaching day.
12. Open questions (module-level)
| # | Item | Impact |
|---|---|---|
| OQ-1 | No class-vs-class overlap check on create (timetable.service.ts:17-22 excludes classId) — is a class double-booked during the same period intentional (e.g. split classes)? | Conflict banner logic, editor warnings |
| OQ-2 | No update/delete endpoints — how are mistakes corrected today (delete+recreate via planned routes)? | Editor UX (edit-in-place vs delete+create) |
| OQ-3 | TimetableEntryCreated is emitted but unrouted (timetable.service.ts:33-44, no event-queue-map.ts entry) — intended consumers? | Notification/audit roadmap |
| OQ-4 | No RBAC metadata on endpoints (timetable.controller.ts:9) despite timetable.read/create existing (permissions.constants.ts:44-45) — who may write? | Permission UI |
| OQ-5 | Time format is unvalidated @IsString (create-timetable-entry.dto.ts:27-33) yet overlap detection depends on zero-padded lexical order — enforce HH:MM server-side? | Validation + conflict correctness |
02 — User Personas (Timetable Module)
- 1. Organization Admin
- 2. Academic Coordinator (scheduling operator)
- 3. Teacher (self — read-only)
- 4. Room / Facilities Manager (derived)
- 5. Persona → surface map
Derived from
blueprint/01-Product/USER_PERSONAS.md(tenant-level actors), the implemented RBAC permission catalog (src/modules/rbac/permissions.constants.ts:44-45), and the actual API surface (src/modules/timetable/**). Personas marked (derived) are role-level specializations inferred from module access patterns, not literal blueprint personas.
1. Organization Admin
- Blueprint anchor: "Manages a single institution (tenant). Goals: Configure
branches, invite staff, oversee operations. Access: Full tenant admin,
configurable permissions." (
USER_PERSONAS.md:20-25) - System role:
org_admin—permissions: ALL_PERMISSIONS(role.schema.ts:17-24), which includestimetable.readandtimetable.create(permissions.constants.ts:44-45). - Goals: a complete, conflict-free weekly timetable before term start; teachers and rooms never double-booked; changes applied fast during term (substitutions).
- Pain points: there is no update/delete endpoint — fixing a mistake means
recreating entries via the planned routes (OQ-2); class-vs-class overlaps are not
detected server-side (
timetable.service.ts:17-22), so the admin carries that check in their head. - Context: desktop-first, term-start bursts; the grid is the primary surface.
- Success: full week built in one session with zero 409s on submit.
2. Academic Coordinator (scheduling operator)
- Blueprint anchor: derived from Staff persona ("coordinators… read access across
modules",
USER_PERSONAS.md:34-39) — the person who actually builds the timetable from teacher availability and room inventory. (derived) - System role: custom role granted
timetable.read+timetable.create(permissions.constants.ts:44-45) — client-side gating only, server is JWT-only (timetable.controller.ts:9, 01 §5). - Goals: lay out the weekly grid per class; balance teacher load; assign rooms;
resolve conflicts as they appear (409 from
timetable.service.ts:28). - Pain points: room view has no API (no
roomIdquery param,timetable.controller.ts:20-29) — must compose it client-side from class grids(planned) for backend support; no bulk create (IMPLEMENTATION_PLAN.md:226). - Context: desktop; heavy keyboard + drag & drop usage (15 §8).
3. Teacher (self — read-only)
- Blueprint anchor: "Delivers instruction. Goals: Mark attendance, assign
homework, enter exam marks. Pain: Wants minimal clicks; mobile-friendly. Access:
Own classes, own students." (
USER_PERSONAS.md:27-32) - System role:
teacher—permissions: ['student.read','attendance.mark','attendance.edit'](role.schema.ts:26-32) — notimetable.*grant; "My schedule" is served byGET /timetable?teacherId=<me>(timetable.controller.ts:23-27) using the teacher's own id, read-only. - Goals: know where to be at any time, in < 10 s, on a phone; see today's classes and the whole week.
- Pain points: no self-resolution endpoint (
GET /timetable/medoes not exist) — the client must know the teacher id; conflicts caused by others appear as double-booking in their grid with no resolution affordance (read-only). - Context: phone-first, during school hours; minimal interaction — view-only grid.
4. Room / Facilities Manager (derived)
- Blueprint anchor: derived from Staff persona + the existence of the Rooms
module (
room.schema.ts:16-35,rooms.controller.ts:20-52). (derived) - System role: custom role with
rooms.read(permissions.constants.ts:50). - Goals: see which room is free when; spot room overlaps; plan lab/hall usage.
- Pain points: the API cannot answer "what's booked in room X" —
GET /timetableonly filters byclassId/teacherId(timetable.controller.ts:22-25). The client merges per-class responses; a native room filter is(planned).
5. Persona → surface map
| Persona | Primary surfaces (this module) | Key endpoints |
|---|---|---|
| Org Admin | Class grid, entry editor, conflict banner | POST /timetable (timetable.controller.ts:14-18), GET /timetable?classId= (:23) |
| Academic Coordinator | Class grid, teacher grid, room view (planned), entry editor | POST /timetable, GET /timetable?classId=, GET /timetable?teacherId= (:26) |
| Teacher (self) | My schedule grid (read-only) | GET /timetable?teacherId=<me> (:26) |
| Room / Facilities Manager | Room schedule (planned) | composed from GET /timetable?classId=; native ?roomId= (planned) |
03 — User Journeys (Timetable Module)
- Journey 1 — Create a timetable entry (Coordinator / Admin)
- Journey 2 — View a class's weekly grid (all personas, read)
- Journey 3 — View a teacher's weekly grid (teacher self / coordinator)
- Journey 4 — Resolve a conflict (Coordinator / Admin)
- Journey 5 — Room schedule view (Facilities Manager) —
(planned) - Failure exits (shared)
Five journeys mapped 1:1 to the implemented API. Endpoint citations:
timetable.controller.ts:14-29,timetable.service.ts:16-69.(planned)= roadmap/documented but not in code.
Journey 1 — Create a timetable entry (Coordinator / Admin)
flowchart TD
A[Class grid → tap empty slot] --> B[Entry editor sheet]
B --> C[Pick class, subject, teacher, day, start/end time, room?, academic year]
C --> D[Submit POST /timetable]
D --> E{Server conflict check}
E -->|409 Schedule conflict detected| F[Inline AppBanner: teacher/room already\n booked in overlapping period]
E -->|400 VALIDATION_ERROR| G[Field errors per DTO]
E -->|200| H[TimetableEntryCreated emitted]
H --> I[Slot appears in grid, server-sorted]
F --> J[Fix time/teacher/room → resubmit]
- Body:
CreateTimetableEntryDto(create-timetable-entry.dto.ts:5-38). - Conflict rule: query matches same-day entries sharing
teacherIdorroomId(timetable.service.ts:17-22); overlapping time (start1 < end2 && start2 < end1,timetable.service.ts:68) → 409. - Not checked: same-class overlap (OQ-1) — client may warn but server accepts.
- Side-effect:
TimetableEntryCreated(timetable.service.ts:33-44) — no queue routing exists (01 §6).
Journey 2 — View a class's weekly grid (all personas, read)
flowchart TD
A[Nav 'Timetable' → pick class] --> B[GET /timetable?classId=<id>]
B --> C[Entries sorted dayOfWeek, startTime]
C --> D[Render 6-day grid Mon..Sat × time rows]
D --> E[Tap slot → detail popover]
D --> F[Empty day → dashed empty cell]
- Server-sorted
{dayOfWeek:1, startTime:1}(timetable.service.ts:48-53) — client renders, never re-sorts. - Day headers from
DayOfWeekenum (timetable.schema.ts:7-14): Monday–Saturday.
Journey 3 — View a teacher's weekly grid (teacher self / coordinator)
flowchart TD
A[Teacher detail → Schedule tab] --> B[GET /timetable?teacherId=<id>]
B --> C[Grid sorted dayOfWeek, startTime]
C --> D{Teacher role?}
D -->|teacher self| E[Read-only grid, no editor affordances]
D -->|coordinator/admin| F[Long-press slot → edit / duplicate entry]
- Same sort contract (
timetable.service.ts:55-60). - A teacher resolving "me" has no
/timetable/meendpoint — client must know the teacher id (OQ-2, teachers03 §5).
Journey 4 — Resolve a conflict (Coordinator / Admin)
flowchart TD
A[Submit entry] --> B{409?}
B -->|yes| C[Conflict banner: 'Teacher already booked\n 10:00-11:00 on Tuesday'\n or 'Room already in use']
C --> D[Options: change time · change teacher ·\n change room · cancel]
D --> E[Resubmit POST /timetable]
E -->|200| F[Grid updates]
E -->|409 again| C
- Client can pre-flight conflicts locally: loaded grids per teacher/room let the editor warn before submit; server remains the source of truth (races possible).
- No partial write: a 409 leaves the grid untouched.
Journey 5 — Room schedule view (Facilities Manager) — (planned)
flowchart TD
A[Rooms module → room detail] --> B{RoomId filter exists?}
B -->|no today| C[Client fetches GET /timetable?classId=\n for every class using the room\n and merges slots]
B -->|planned| D[GET /timetable?roomId=<id>]
C --> E[Render merged room grid, read-only]
D --> E
- No
roomIdquery param onGET /timetabletoday (timetable.controller.ts:22-25) — the merged client view is a documented composition, not an invention of API surface; native filter is(planned)perIMPLEMENTATION_PLAN.md:226("conflicts… export" phase).
Failure exits (shared)
401 → silent refresh → sessionExpired; 429 → "try again in a moment", no auto-retry;
5xx → generic + requestId + retry (per 00-shared/07 §11).
04 — Information Architecture (Timetable Module)
- 1. Placement in the app shell
- 2. Sitemap (client, this module)
- 3. Screen relationships
- 4. Navigation details
- 5. Role → surface gating (client-side; server enforces JWT only —
01 §5) - 6. Information units (primary entities)
- 7. Empty-state strategy
Fits the shared shell (00-shared/05_Global_Information_Architecture.md). Timetable is a scheduling workspace over the Academics/Teachers/Rooms catalogs: routes live under an
Academics/Schedulingdestination, keyed by the entity being scheduled (class, teacher, room).
1. Placement in the app shell
- Primary home:
Academicsdestination (admin/coordinator role-gated) — the class grid is the entry surface, per-class navigation following the 05_Global_Information_Architecture.md:39 shell pattern. - Teacher schedule: secondary entry from Teacher detail · Schedule tab
(
GET /timetable?teacherId=,timetable.controller.ts:26); and a teacher-self "My Schedule" surface(planned). - Room schedule: tertiary, from Rooms module detail — client composition today,
native
(planned)(see 03 §J5). - Cross-links: Academics (classes, subjects, academic years), Teachers,
Rooms, Notifications (
TimetableEntryCreatedconsumers(planned)).
2. Sitemap (client, this module)
Academics (Scheduling)
└── Timetable
├── Class Timetable /academics/timetable?classId=:id
│ ├── Week view (default, current week)
│ ├── Today view (filtered)
│ └── Entry Editor (sheet/dialog) → /academics/timetable/new
├── Teacher Timetable /academics/timetable/teacher/:teacherId
│ └── (same grid, read-only for teacher role)
└── Room Timetable (planned) /academics/timetable/room/:roomId
Teacher (self)
└── My Schedule (planned) /my/schedule
Rooms
└── Room Detail → Schedule tab /rooms/:id/schedule
3. Screen relationships
- Class grid → tap empty slot → Entry Editor (bottom sheet on phone, dialog on
desktop); tap filled slot → detail popover → "Edit"
(planned)/ "Duplicate". - Class grid ↔ Teacher grid: switch scope via segmented control on the same route —
data source swaps between
?classId=and?teacherId=(timetable.controller.ts:23-26). - Grid → Room view: open from a slot's room chip (composed view,
03 §J5). - Conflict banner is inline state of the editor, not a page.
4. Navigation details
| Aspect | Rule |
|---|---|
| Route params | classId / teacherId / roomId = Mongo ObjectIds (timetable.schema.ts:18-28) |
| Grid scope state | current scope (class | teacher | room) + week offset — kept in shell state across navigation (05 §3) |
| Deep link | studylyon://timetable?classId=:id; studylyon://timetable/teacher/:teacherId |
| Breadcrumbs | desktop: Academics › Timetable › {Class name} |
| FAB | Grid screens: "Add slot" opens the editor prefilled with the current class |
| Context menu | Slot long-press / right-click: Edit (planned), Duplicate, Delete (planned) |
| Keyboard | + / N = new slot; ←/→ = week navigation (desktop) |
5. Role → surface gating (client-side; server enforces JWT only — 01 §5)
| Surface | org_admin / coordinator (timetable.create) | teacher (self) | read-only staff |
|---|---|---|---|
| Class grid | full | — | read (timetable.read, permissions.constants.ts:44) |
| Teacher grid | full | own only (planned) | read |
Room view (planned) | full | — | read |
| Entry editor (create) | full | — | — |
Edit / delete (planned) | full | — | — |
Server reality: no endpoint carries RBAC metadata (timetable.controller.ts:9);
cross-tenant access is still structurally blocked via scopedFilter
(base.repository.ts:20-30).
6. Information units (primary entities)
| Entity | Collection | Key fields | Client model |
|---|---|---|---|
| TimetableEntry | timetable_entries | classId, subjectId, teacherId, roomId?, dayOfWeek, startTime, endTime, academicYearId | TimetableEntry |
| Class (ref) | classes | name, gradeId, sectionId, academicYearId, roomId (string, distinct) | Class (reference) |
| Subject (ref) | subjects | code, name, shortName | Subject (reference) |
| Teacher (ref) | teachers | employeeNumber, employmentStatus, userId | Teacher (reference) |
| Room (ref) | rooms | name, code, capacity, type | Room (reference) |
| AcademicYear (ref) | academic_years | name, startDate, endDate, isCurrent | AcademicYear (reference) |
Server returns raw ObjectIds for all refs (timetable.schema.ts:18-40) — the
client joins names from catalogs; missing ref id renders "—".
7. Empty-state strategy
- Grid with zero entries →
AppEmptyState"No classes scheduled" + CTA "Add slot". - Single empty day → dashed empty cells, no empty-state page.
- Teacher grid empty → "No classes scheduled" (no editor CTA for teacher role).
- Room view with no matches → "No bookings for this room".
- Conflict submit → inline
AppBanner, never an empty state.
05 — Screen Inventory (Timetable Module)
- Legend
- S1 — Class Timetable Grid (
/academics/timetable?classId=:id) - S2 — Teacher Timetable Grid (
/academics/timetable/teacher/:teacherId) - S3 — Room Timetable Grid (
/academics/timetable/room/:roomId)(planned) - S4 — Entry Editor (bottom sheet on phone / dialog on desktop)
- S5 — Conflict banner (inline state, not a page)
- S6 — Week Navigation & Today View
- Shared components used
- Analytics events (proposed)
- Keyboard, landscape, tablet, desktop
Every screen of the Timetable module, its intent, route, composition, states, permissions, platform behaviour and events. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics. The weekly grid is the hero component — see 06 §S1 and 07 §A1.
Legend
States = idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission.
Analytics events follow {module}.{screen}.{action} (proposed; SDK open — 00-shared/10 §8).
S1 — Class Timetable Grid (/academics/timetable?classId=:id)
| Field | Detail |
|---|---|
| Purpose | Weekly grid for one class: 6 day columns × time rows; slot cards = subject · teacher · room |
| Entry | Academics nav → pick class; deep link; FAB "Add slot" |
| Exit | tap slot → S4; scope switch → S2; room chip → S3 (planned) |
| Inputs | scope picker (class/teacher/room), week navigator, class picker |
| Primary CTA | FAB "Add slot" (opens S4 prefilled with class) |
| Source | GET /timetable?classId= (timetable.controller.ts:23), server-sorted dayOfWeek, startTime (timetable.service.ts:48-53) |
| States | loading (skeleton grid), empty (no entries), error, offline (cached last-good + banner), permission (timetable.read, permissions.constants.ts:44) |
| Analytics | timetable.class.view, timetable.slot.tap (proposed) |
| Adaptive | phone: vertical list of days with horizontal time strip; tablet/desktop: true 6-column grid, drag & drop (15 §8) |
| a11y | each slot a single button semantics with full label; grid headers announced; focus order left→right, top→bottom |
| Motion | grid fade m-fast; slot insert/remove AnimatedList m-base (10 §4) |
S2 — Teacher Timetable Grid (/academics/timetable/teacher/:teacherId)
| Field | Detail |
|---|---|
| Purpose | Same grid component, teacher scope; read-only for teacher self |
| Entry | Teacher detail · Schedule tab; scope switch from S1; deep link |
| Source | GET /timetable?teacherId= (timetable.controller.ts:26), sorted dayOfWeek, startTime (timetable.service.ts:55-60) |
| Writes | none for teacher role — no editor affordances (02 §3) |
| States | as S1; "no classes scheduled" empty state (no CTA for teachers) |
| Analytics | timetable.teacher.view (proposed) |
S3 — Room Timetable Grid (/academics/timetable/room/:roomId) (planned)
| Field | Detail |
|---|---|
| Purpose | Booked slots per room, read-only |
| Entry | slot's room chip; Rooms module detail |
| Source | no roomId filter exists (timetable.controller.ts:22-25) → client merges GET /timetable?classId= responses for classes using the room; native filter (planned) (IMPLEMENTATION_PLAN.md:226) |
| States | as S1; "No bookings for this room" |
| Analytics | timetable.room.view (proposed) |
S4 — Entry Editor (bottom sheet on phone / dialog on desktop)
| Field | Detail |
|---|---|
| Purpose | Create one timetable entry (the only server write) |
| Entry | FAB / empty cell tap / slot "Duplicate" |
| Exit | success → grid insert; cancel; 409 conflict → stays open with banner |
| Inputs | class ▾, subject ▾, teacher ▾, room ▾ (optional), day ▾, start time, end time, academic year ▾ (default isCurrent) — full spec 08 F1 |
| Primary CTA | "Save" (disabled until valid; double-submit guard) |
| Source | POST /timetable (timetable.controller.ts:14-18), CreateTimetableEntryDto (create-timetable-entry.dto.ts:5-38) |
| States | idle / submitting / 400 field errors / 409 conflict banner (timetable.service.ts:28) / 5xx |
| Conflict UX | AppBanner: "Teacher already booked {day} {start}–{end}" or "Room already in use…" — derived from the single server message + local pre-flight (06 §S4) |
| Analytics | timetable.entry.submit, timetable.entry.conflict, timetable.entry.success (proposed) |
S5 — Conflict banner (inline state, not a page)
| Field | Detail |
|---|---|
| Purpose | Surface the single server conflict rule: teacher or room double-booking on the same day with overlapping time (timetable.service.ts:16-30,62-69) |
| Trigger | 409 from POST /timetable; also local pre-flight warnings before submit (races possible — server authoritative) |
| Placement | inside S4, above the submit button |
| Actions | dismiss; "Change time/teacher/room" (focus related picker) |
| Copy | timetable.conflict.teacher / timetable.conflict.room — one generic server message ("Schedule conflict detected", timetable.service.ts:28) mapped to the specific pre-flight cause |
| a11y | live-region announce; errorContainer tokens (11 §2) |
S6 — Week Navigation & Today View
| Field | Detail |
|---|---|
| Purpose | Move between weeks; collapse to today |
| Behaviour | WeekNavigator (07 §A7): ‹ today › offset −1..0..+n (forward-looking: weekly shift) — pure client state, no server concept of "week"; entries are weekly-recurring by model (timetable.schema.ts:30-37 stores weekday + time only, no date) |
| Note | The backend has no date dimension — week navigation merely re-filters the same 6-day data; push notifications for "class in 15 min" (forward-looking) would need a new date-aware model |
Shared components used
AppDropdown, AppButton, AppSnackbar, AppCard, AppBottomSheet, AppDialog,
AppMenu, AppSkeleton, AppEmptyState, AppErrorState, AppOfflineBanner,
AppFAB, AppChips, AppBadge, AppTabs, AppSectionHeader, AppTimePicker-style
time field, AppSearchBar-style searchable pickers. Module-specific: TimetableGrid,
TimetableSlotCard, DayHeader, TimeGutter, GridCell, ConflictBanner,
WeekNavigator — defined in 07_Component_Library.md.
Analytics events (proposed)
timetable.{class,teacher,room}.view, timetable.slot.tap,
timetable.entry.{submit,conflict,success},
timetable.week.{prev,next,today}, timetable.conflict.dismiss (all proposed).
Keyboard, landscape, tablet, desktop
- Phone portrait: day-strip list; landscape/tablet: grid with fixed day headers.
- Desktop: true grid + drag & drop (15 §8),
+/Nnew slot,←/→week nav. - Grid scrolls horizontally on narrow widths; time gutter + day headers pinned (15 §9).
06 — Screen Specifications (Timetable Module)
- S1 — Class Timetable Grid (hero)
- S2 — Teacher Timetable Grid
- S3 — Room Timetable Grid —
(planned) - S4 — Entry Editor (bottom sheet on phone / dialog on desktop)
- S5 — Conflict Banner (spec)
- S6 — Week Navigation
The definitive per-screen spec. Wire contract per 00-shared/07. All field names and rules cite the DTO/schema they come from.
(planned)= backend/roadmap gap. The weekly grid (S1) is the hero component — largest spec, shared by S2/S3.
S1 — Class Timetable Grid (hero)
Layout (phone / tablet / desktop)
AppBar: "Timetable · {Class name}" [scope ▾ class|teacher] [⋮]
─────────────────────────────────────────────────────────────
[Class picker ▾] [WeekNavigator ‹ Week of 03 Aug › Today]
─────────────────────────────────────────────────────────────
TimeGutter | Mon | Tue | Wed | Thu | Fri | Sat
08:00 | [slot] | | [slot] | | |
09:00 | [slot] | [slot] | | [slot] | [slot] |
10:00 | | | [slot] | | |
... (rows = distinct startTime across the week)
─────────────────────────────────────────────────────────────
FAB: "Add slot"
- Columns: 6 day columns from
DayOfWeekenum —monday…saturday(timetable.schema.ts:7-14). No Sunday column (Sunday is not a legal value). - Rows: derived from the union of
startTimevalues present in the week's data, sorted ascending (server contractdayOfWeek, startTime,timetable.service.ts:51). A slot spans row-height × duration (endTime − startTime), minimum 1 row. - Slot card content (TimetableSlotCard,
07 §A2):{subject shortName}(titleSmall),{teacher name}·{room code}(bodySmall), optional room icon. Conflicts (same teacher/room in another slot of the SAME grid) get anerrorContainerborder + warning icon — client-derived (server allows same-class overlaps, OQ-1). - Breakpoints: phone → single-column day-strip list (day header + slots stacked);
tablet (≥ 600 dp) → 2-day panes; desktop (≥ 1024 dp) → full 6-column grid,
drag & drop enabled (
15 §8).
Behaviour & states
| State | UI | Source |
|---|---|---|
| Loading | skeleton grid (6 columns × 6 ghost rows, shimmer) | 00-shared/06 §3.1 |
| Success + data | grid, slots placed | — |
| Success + empty | AppEmptyState "No classes scheduled" + CTA "Add slot" | — |
| Error | AppErrorState(code, message, retry) | envelope error.code |
| Offline | cached last-good grid + AppOfflineBanner | 00-shared/06 §3.3 |
| Permission | timetable.read gate (permissions.constants.ts:44) | client-side, 01 §5 |
Data & interactions
- Fetch:
GET /timetable?classId=<id>(timetable.controller.ts:23) — non-paginated array, server-sorteddayOfWeek, startTime(timetable.service.ts:48-53). Client never re-sorts. - Both
classId+teacherIdsupplied →classIdwins (timetable.controller.ts:26-27). - Tap empty cell → S4 (entry editor, prefilled class + day + time).
- Tap slot → detail popover (subject, teacher, room, time, year) with actions:
Duplicate (opens S4 prefilled), Edit/Delete
(planned)— no endpoints exist (01 §10). - Desktop drag & drop: drag slot card onto a target cell → opens S4 prefilled with
the new day/time and the slot's subject/teacher/room (a create, since no PATCH
exists; original remains until the new entry is saved and the user deletes it
(planned)— see10 §2and OQ-2). - Long-press slot (mobile) → same actions as detail popover (
10 §1). - Pull-to-refresh bypasses cache; scope switch re-fetches; week navigator re-filters
locally (no server date concept,
05 §S6).
Accessibility
- Grid has one
Semanticscontainer per slot: "Mathematics, R. Kumar, Room 201, Monday 08:00–09:00". - Headers:
dayOfWeek+ time gutter announced as column/row headers (09 §6). - Conflict slots never color-only — border + warning icon + label ("double-booked",
09 §9). - Keyboard (desktop): arrow keys move cell focus;
Enteropens editor; focus ringprimary2 dp (09 §6).
S2 — Teacher Timetable Grid
- Same grid component, scope = teacher:
GET /timetable?teacherId=<id>(timetable.controller.ts:26), sorteddayOfWeek, startTime(timetable.service.ts:55-60). - Slot content:
{subject shortName} · {class name}(teacher's own slots). - Teacher role → read-only: no FAB, no drag & drop, no long-press editor actions
(
02 §3); slot tap shows detail popover only. - Empty → "No classes scheduled" (no CTA for teacher role).
- Coordinator/admin view additionally shows per-slot room code and opens S4 on empty cells.
S3 — Room Timetable Grid — (planned)
- Client composition: fetch
GET /timetable?classId=for every class whose default room or entries reference the room; merge slots; render read-only. - No
roomIdfilter on the API (timetable.controller.ts:22-25); native?roomId=is(planned)(IMPLEMENTATION_PLAN.md:226). - Slot content:
{subject shortName} · {class name} · {teacher}. - Empty → "No bookings for this room".
S4 — Entry Editor (bottom sheet on phone / dialog on desktop)
Layout
Sheet "New timetable slot" [Save]
──────────────────────────────────────────────────
Class ▾ (required, prefilled from grid)
Subject ▾ (required, searchable, GET /subjects)
Teacher ▾ (required, searchable, GET /teachers)
Room ▾ (optional, GET /rooms)
Day ▾ (Mon..Sat — from DayOfWeek enum)
Start time 08:00 (24 h, HH:MM — MUST be zero-padded)
End time 09:00 (> start; client-enforced)
Academic year ▾ (default isCurrent)
──────────────────────────────────────────────────
[ConflictBanner — only when 409 / pre-flight warning]
Field-by-field validation (server DTO vs client)
| Field | Server (create-timetable-entry.dto.ts) | Client rules |
|---|---|---|
| classId | required @IsMongoId (6-8) | required; prefilled |
| subjectId | required @IsMongoId (10-12) | required picker |
| teacherId | required @IsMongoId (14-16) | required picker |
| roomId | optional @IsOptional @IsMongoId (18-21) | optional picker |
| dayOfWeek | required @IsEnum(DayOfWeek) (23-25) | 6 values from timetable.schema.ts:7-14 |
| startTime | required @IsString (27-29) — format unvalidated | 24 h HH:MM (e.g. 08:00); regex `^([01]\d |
| endTime | required @IsString (31-33) — no >start check server-side | end > start client-enforced (proposed); 1-row minimum |
| academicYearId | required @IsMongoId (35-37) | required; default isCurrent from GET /academic-years |
Pre-flight conflict check (client, best-effort)
- Before submit, consult cached per-day grids: same
teacherIdoverlapping → warn "Teacher already booked {day} {start}–{end}"; sameroomIdoverlapping → warn "Room already in use". Server remains authoritative (409, races possible). - Same-class overlap is NOT warned — server permits it (OQ-1); editor copy must not claim a class-level conflict check.
Submit & errors
POST /timetable(timetable.controller.ts:14-18) → 200data= entry doc.- 409
ConflictException"Schedule conflict detected" (timetable.service.ts:28) →ConflictBannerin-sheet, form values kept, focus conflicting picker. - 400 → map
error.details[].fieldto form fields (00-shared/07 §3). - Double-submit guard; offline → blocked with banner (no offline write queue).
- Success → slot inserted into grid (
AnimatedList,10 §4), snackbar "Slot added".
S5 — Conflict Banner (spec)
| Aspect | Detail |
|---|---|
| Trigger | 409 on submit; pre-flight warning on picker change |
| Placement | S4, between fields and Save |
| Copy | teacher: "Teacher already booked {day} {start}–{end}" / room: "Room already in use {day} {start}–{end}" — mapped from the generic server message + local clash data |
| Actions | [Dismiss] [Change time] (focuses time fields) |
| Tokens | errorContainer/onErrorContainer, warning icon event_busy (11 §2) |
| A11y | SemanticsService.announce on appear (live region) |
S6 — Week Navigation
WeekNavigator(07 §A7):‹/›shift displayed week,Todayresets.- Pure client state (no server date dimension —
timetable.schema.ts:30-37stores weekday + time only); week offset stored in shell state (04 §4). - Future date-aware model (specific dates, push reminders)
(forward-looking).
07 — Component Library (Timetable Module)
- A. Module components
- TimetableGrid (hero component, extends AppScrollContainer)
- TimetableSlotCard (extends AppCard)
- GridCell
- DayHeader / TimeGutter
- ConflictBanner (extends AppBanner)
- WeekNavigator
- EntryEditorSheet (extends AppBottomSheet / AppDialog)
- SlotDetailPopover (extends AppMenu surface)
- TeacherScheduleTab (composition)
- B. Reused shared components
- C. Composition rules
Module-specific components; all reference the shared library (00-shared/03). Every component listed here is a client composition over the implemented API — none invent backend data.
A. Module components
TimetableGrid (hero component, extends AppScrollContainer)
- Props:
entries: List<TimetableEntry>,scope(class | teacher | room),catalogs(subject/teacher/room/class name resolvers),readOnly,onCellTap,onSlotTap,onSlotDrag(desktop),onLongPress. - Structure: pinned
TimeGutter(left) + 6DayHeaders (Mon–Sat, fromDayOfWeek,timetable.schema.ts:7-14) + row ofGridCells; rows = union ofstartTimevalues, ascending (server sort contract,timetable.service.ts:51,58). - Slot placement: absolute-positioned
TimetableSlotCards within each day column, height ∝endTime − startTime(min 1 row). - Perf: row/cell widgets built via
ListView.builder-style lazy construction;RepaintBoundaryper slot card; scroll controllers shared gutter/header (15 §9). - Desktop: each slot wrapped in
Draggable<TimetableEntry>; cells areDragTarget<TimetableEntry>(10 §2, 15 §8). Mobile:LongPressDraggableoff — long-press opens actions instead. - A11y: single
Semanticsnode per slot with full label (06 §S1).
TimetableSlotCard (extends AppCard)
- Props:
entry(subjectLabel, teacherLabel, roomLabel, startTime, endTime),conflict: bool(client-derived same-grid overlap),onTap,onLongPress,onDrag(desktop). - Content:
{subject shortName}(titleSmall),{teacher} · {room}(bodySmall); conflict →errorContainerborder +event_busyicon + label "double-booked". - Room label shows room
code(room.schema.ts:21-22) — absent room → no chip. - Tokens:
surfaceContainerLow,e-1; hover (desktop)e-2+ tint (10 §2).
GridCell
- Props:
day,startTime,endTimeimplicit row,onTap(empty cell → S4),acceptsDrop(desktop). - Empty state: dashed outline (
outlineVariant), subtle hover fill. - A11y: cell tap targets ≥ 44 dp; announced "Monday, 08:00, free".
DayHeader / TimeGutter
DayHeader: day label (short) + date-of-current-week (client computed); pinned top, sticky.TimeGutter:HH:MMlabels (mono,tabularFigures, 02 §2), pinned left.- A11y: announced as column/row headers (06 §S1).
ConflictBanner (extends AppBanner)
- Props:
kind: teacher | room,clash(day, startTime, endTime, otherPartyLabel),onDismiss,onChangeTime. - Copy: "Teacher already booked Tue 08:00–09:00 (R. Kumar)" / "Room already in use Tue 08:00–09:00 (Room 201)".
- Tokens:
errorContainer/onErrorContainer(11 §2); live-region announce.
WeekNavigator
- Props:
offset,onPrev,onNext,onToday. - Content:
‹[Week of {date} · {day range}]›+ "Today" text button. - Pure client state (06 §S6); disabled state: none (always navigable).
- Keyboard:
←/→when grid focused (desktop).
EntryEditorSheet (extends AppBottomSheet / AppDialog)
- Props:
prefill(classId, day, startTime, endTime, subject/teacher/room from duplicate),onSaved(entry),onConflict(409). - Hosts the F1 form (08) +
ConflictBanner; phone sheetr-xltop corners, desktop dialoge-4.
SlotDetailPopover (extends AppMenu surface)
- Props:
entrywith resolved names; actions: Duplicate (→ editor), Edit/Delete(planned)— hidden unlesstimetable.create+ coordinator role.
TeacherScheduleTab (composition)
- Wraps
TimetableGridwith teacher scope (GET /timetable?teacherId=,timetable.controller.ts:26) in the Teachers module's detail screen — same grid, read-only for teacher role.
B. Reused shared components
| Shared component | Used in |
|---|---|
AppDropdown | scope picker (S1/S2), class/day/year pickers (S4) |
AppSearchBar-style searchable picker | subject/teacher/room pickers (S4) |
AppSkeleton | grid loading ghost |
AppEmptyState | S1/S2/S3 empty |
AppErrorState | grid/editor errors (envelope code) |
AppBanner | offline reads, conflict (via ConflictBanner) |
AppSnackbar | "Slot added", "Slot removed (planned)" |
AppFAB | S1 "Add slot" (hidden for teacher role) |
AppButton | Save (filled), Cancel (text), Change time (outlined) |
AppTimePicker-style field | start/end time inputs (S4) |
AppChips | day picker quick-select (S4) |
AppOfflineBanner | module-wide offline reads |
AppSectionHeader | grid section titles, editor groups |
AppDialog | destructive confirm (planned), editor on desktop |
C. Composition rules
- No screen renders raw ObjectIds — every ref resolved via catalogs (subjects, teachers, rooms, classes, years) or "—" fallback (04 §6).
DayOfWeekdisplay order fixed: Mon→Sat (timetable.schema.ts:7-14) — never locale-reordered.- Conflict styling centralized in
TimetableSlotCard/ConflictBanner— no ad-hoc red in screens (11 §2). - Times rendered
mono+tabularFigureseverywhere (gutter, cards, editor). - New components only if shared library lacks the need (YAGNI — check shared first).
- Golden tests:
TimetableSlotCard(normal/conflict × light/dark),TimeGutter,ConflictBanner,WeekNavigator(00-shared/03 §G).
08 — Form Specifications (Timetable Module)
Field-by-field specs. Server column = exact DTO contract (
create-timetable-entry.dto.ts). Weak-validation warnings are derived facts (the DTO validates types/ids but not time format or ordering).
F1 — Create Timetable Entry (POST /timetable)
| # | Field | Type | Required | Server validation | Client input | Default |
|---|---|---|---|---|---|---|
| 1 | classId | string (ObjectId) | ✅ | @IsMongoId (create-timetable-entry.dto.ts:6-8) | Searchable picker from GET /classes (or prefilled from grid scope) | — |
| 2 | subjectId | string (ObjectId) | ✅ | @IsMongoId (:10-12) | Searchable picker from GET /subjects | — |
| 3 | teacherId | string (ObjectId) | ✅ | @IsMongoId (:14-16) | Searchable picker from GET /teachers | — |
| 4 | roomId | string (ObjectId) | — | @IsOptional @IsMongoId (:18-21) | Picker from GET /rooms (code + name) | unset (schema optional, timetable.schema.ts:27-28) |
| 5 | dayOfWeek | string enum | ✅ | @IsEnum(DayOfWeek) (:23-25) | 6 chips Mon–Sat from DayOfWeek (timetable.schema.ts:7-14) | current day of week (proposed) |
| 6 | startTime | string | ✅ | @IsString (:27-29) — format NOT validated | AppTimePicker 24 h → HH:MM zero-padded (08:00); regex `^([01]\d | 2[0-3]):[0-5]\d$ (proposed)` |
| 7 | endTime | string | ✅ | @IsString (:31-33) — no > start check | AppTimePicker; client enforces end > start (proposed) | 09:00 |
| 8 | academicYearId | string (ObjectId) | ✅ | @IsMongoId (:35-37) | AppDropdown from GET /academic-years, default isCurrent (academic-year.schema.ts:31-32) | current year |
Client-side rules (server gaps — derived)
- Time format is load-bearing: overlap detection compares strings lexically
(
start1 < end2 && start2 < end1,timetable.service.ts:62-69). It is correct only for zero-paddedHH:MM. The server never validates format/order, so the client:- forces 24 h
HH:MMinput (picker-based, no free text), - blocks
end <= startbefore submit (a non-ordered pair would maketimeOverlapsmath meaningless), - never sends
"8:00"/"9:00"(would silently break conflict detection).
- forces 24 h
end == startis allowed as a boundary (back-to-back slots legal —timeOverlapsreturns false,timetable.service.ts:68); client should still prefer ≥ 1-row gaps for readability(proposed).- Class-level overlap is not a server error (OQ-1) — the editor must NOT show a class-conflict error; server accepts.
- Room-less entries: conflict detection reduces to teacher-only (the
roomId: undefinedquery clause is stripped by Mongoose;timetable.service.ts:20). - Pre-flight: on picker change, consult cached per-day grids; warn for teacher/room clashes (server 409 remains authoritative — races possible).
Submit payload (exact)
{ "classId": "…", "subjectId": "…", "teacherId": "…", "roomId": "…",
"dayOfWeek": "monday", "startTime": "08:00", "endTime": "09:00",
"academicYearId": "…" }
(roomId omitted when unset — never send null/""; DTO whitelist +
forbidNonWhitelisted pipes reject unknown keys, main.ts:50-57.)
Server responses
- 200
data= created entry doc (envelope-unwrapped; timestamps auto,timetable.schema.ts:16). - 409
ConflictException"Schedule conflict detected" (timetable.service.ts:28) →ConflictBanner, form values kept. No structured error code — client matches on 409 status. - 400
VALIDATION_ERROR: field details mapped (00-shared/07 §3); invaliddayOfWeek(e.g."sunday") fails@IsEnum(:23-25). - Notes:
tenantIdnever sent (from token,base.repository.ts:33-35);forbidNonWhitelistedmeans the editor sends exactly the 8 fields above.
F2 — Update / Delete Timetable Entry — (planned)
- No endpoints exist — no
PATCH /timetable/:id, noDELETE /timetable/:id(timetable.controller.ts:10exposes onlyPOST+GET). Any edit/delete UI is(planned)pending backend (roadmap row "Timetable: Bulk create, conflicts, substitution, workload, export",IMPLEMENTATION_PLAN.md:226). - Interim UX: "Duplicate & correct" — open F1 prefilled from the slot; original stays until a delete surface exists (OQ-2).
09 — User Behaviour (Timetable Module)
- 1. Global behaviours
- 2. Grid screens (S1/S2/S3)
- 3. Entry editor (S4)
- 4. Conflict rules (derived from the server rule)
- 5. Drag & drop / edit semantics (desktop,
(planned)-aware) - 6. Teacher self (read-only)
- 7. Error copy table (module)
- 8. Behavioural edge cases (derived)
Rules of engagement: what the UI may/must not do, derived strictly from backend behaviour. Contradictions with
PLAN.md/ flow docs are flagged, not resolved by invention.
1. Global behaviours
| Rule | Source |
|---|---|
Every list is tenant-scoped automatically; client never sends tenantId | base.repository.ts:20-30,33-35 |
Every request carries Bearer JWT; 401 → single-flight refresh → sessionExpired | 00-shared/06 §3.6 |
| Soft-deleted records are invisible — 404, not a flag in payload | base.repository.ts:20-30 |
| Server returns raw ObjectIds for refs — client joins names from catalogs; missing ref = "—" | timetable.schema.ts:18-40 |
success:false never renders raw message for 5xx — generic + requestId; 4xx business text allowed | 00-shared/07 §11 |
2. Grid screens (S1/S2/S3)
- Never re-sort:
GET /timetable?classId=/?teacherId=return entries sorteddayOfWeek, startTime(timetable.service.ts:48-60); the grid maps them to cells directly. - One request per scope; scope switch (class ↔ teacher) re-fetches — no merged
single-request option exists (
timetable.controller.ts:22-25). - Both params sent →
classIdwins (timetable.controller.ts:26-27); neither →[](:28) — the client always sends exactly one. - Week navigation is a local re-filter (no server "week" or date dimension;
timetable.schema.ts:30-37). - Room view (S3) merges multiple class responses client-side — never claim a server
roomIdfilter exists (OQ-4;(planned)).
3. Entry editor (S4)
- Never optimistic — create is server-confirmed (00-shared/06 §3.5).
- On 409: keep form values;
ConflictBanner; focus the conflicting picker. - On 400: map
details[].field→ inline field errors; focus first invalid (09_Accessibility_Baseline.md §10). - On 5xx/network: keep form values,
AppErrorStateinline with Retry. - Offline: form entry blocked with
AppOfflineBanner(no offline write queue, 00-shared/06 §3.7). - Pre-flight warnings are advisory only — the server is authoritative; two admins submitting the same slot both get a 409 for the second one, never corruption.
4. Conflict rules (derived from the server rule)
- The server checks teacher and room clashes on the same
dayOfWeekwith overlapping[startTime, endTime)intervals (timetable.service.ts:17-22,62-69). endTime == startTimeof another entry → no conflict (back-to-back allowed,:68).- Room-less entries only clash on teacher (
:20— undefined clause stripped). - Same-class overlaps are not detected (OQ-1) — the UI may highlight them in the grid (client-derived) but must not block or claim server support.
5. Drag & drop / edit semantics (desktop, (planned)-aware)
- Dropping a slot onto a new cell opens the editor prefilled (a create — no PATCH
exists, 01 §10). The original entry is untouched until a delete surface exists;
UI copy must be explicit ("This creates a new slot — the original stays until you
delete it
(planned)"). - No optimistic move animations that imply persistence.
6. Teacher self (read-only)
- Read-only grid; no FAB/editor affordances (02 §3).
- Teacher id for "me": client-resolved (no
/timetable/me; teachers module has the same gap, teachers 03 §5).
7. Error copy table (module)
| Code | Screen | Client message (i18n key) |
|---|---|---|
| 409 conflict | S4 | timetable.conflict.teacher / timetable.conflict.room — "Teacher already booked {day} {start}–{end}" / "Room already in use…" (generic server message mapped via pre-flight data) |
| 400 | S4 | per-field from details |
| 404 (catalog refs missing) | grid | slot shows "—", no error screen |
| 429 | all | errors.rateLimited — "Too many requests. Try again in a moment." |
| 5xx | all | errors.server + requestId |
8. Behavioural edge cases (derived)
GET /timetablewith no params →[](timetable.controller.ts:28) — a bare "all entries" fetch is not supported; client must always scope.- Non-zero-padded time strings would break conflict math (
timetable.service.ts:68) — client picker-only input prevents it (08 F1). "sunday"rejected by@IsEnum(create-timetable-entry.dto.ts:23-25) — no Sunday column exists anywhere in the UI.TimetableEntryCreatedis emitted (timetable.service.ts:33-44) but no queue routes it — the UI must not promise notifications/audit from timetable events (01 §6).- Cross-tenant entry id in a URL → 404 (scoped
find,base.repository.ts:20-30) — treat as removed, never leak existence (00-shared/07 §3).
10 — Interaction Specification (Timetable Module)
- 1. Touch gestures (phone/tablet)
- 2. Desktop/web (mouse + keyboard)
- 3. Haptics (mobile)
- 4. Motion per screen
- 5. Micro-interactions
- 6. Frustration controls
Specializes 00-shared/08 for this module. All motion tokens from 02_Design_Tokens.md §6. The grid is the hero surface: drag-to-edit on desktop, long-press on mobile (06 §S1, 07 §A1).
1. Touch gestures (phone/tablet)
| Gesture | Behaviour | Screen |
|---|---|---|
| Tap empty cell | Open S4 editor prefilled (class + day + time of cell) | S1/S2 (coordinator) |
| Tap slot | SlotDetailPopover: subject/teacher/room/time + actions (Duplicate, Edit/Delete (planned)) | S1/S2/S3 |
| Long-press slot | Same popover as tap (selectionClick haptic) — no drag on mobile; edit happens in the editor, not by dragging | S1 |
Long-press on TimetableSlotCard conflict badge | Tooltip/semantics "Double-booked" detail (proposed) | S1 |
| Swipe left on slot | Remove — (planned) (no DELETE endpoint; disabled until backend lands) | S1/S2 |
| Pull down | Refresh grid (cache-bypass) | S1/S2/S3 |
| Tap FAB | Scale-in (m-entrance) → S4 | S1 (coordinator) |
Tap ‹/› | Week shift m-base cross-fade | S1/S2/S3 |
| Back gesture | Editor sheet → grid (discard confirm if dirty (proposed)); grid → previous surface | S4 |
2. Desktop/web (mouse + keyboard)
| Input | Behaviour |
|---|---|
| Hover slot | m-fast surface tint, e-2; tooltip after 500 ms (full label) (08 §2) |
| Hover empty cell | subtle fill; drop-target highlight primaryContainer when a slot is dragged over |
| Drag slot → cell | Draggable<TimetableEntry> → DragTarget cell: drop opens S4 editor prefilled with new day/time + slot's subject/teacher/room. Creates a new entry — no PATCH exists; banner in editor: "This creates a new slot; the original stays until you delete it (planned)" (09 §5) |
| Drag to invalid target (occupied non-empty cell) | Rejected: cell shows errorContainer outline; slot returns (spring m-base) |
| Right-click slot | Same popover as long-press |
+ / N | New slot (S4) on grid screens (05_Global_Information_Architecture.md §7) |
← / → | Week navigation (grid focused) |
Ctrl+F | Focus scope/class search (proposed) |
Esc | Close editor sheet/dialog/popover |
Enter | Submit focused editor; Tab logical order; visible focus ring primary 2 dp (09 §6) |
Delete key | On focused slot → delete confirm (planned) |
3. Haptics (mobile)
| Event | Haptic |
|---|---|
| Entry created (200) | lightImpact() |
| Conflict (409 / pre-flight) | mediumImpact() + banner |
| Slot long-press popover | selectionClick() |
| Pull-to-refresh done | lightImpact() |
| Rule | disabled when system haptics off; none on web/desktop (08 §3) |
4. Motion per screen
| Screen | Entrance | Element motion |
|---|---|---|
| S1/S2 grid | page fade+rise m-slow | slot insert/remove: AnimatedList m-base; week shift cross-fade m-base; conflict border pulse once m-fast |
| S3 room view | grid fade m-fast | merge updates cross-fade m-fast |
| S4 editor | sheet slide-up m-base, r-xl corners (phone) / dialog scale m-base (desktop) | picker options m-fast; conflict banner slide-down m-base; Save spinner replaces label |
| S5 banner | slide-down m-base | dismiss slide-up m-fast |
| S6 week nav | — | offset label cross-fade m-fast |
Reduced-motion: all collapse to fades ≤ m-instant, no stagger, no drag ghost
animation (08 §4, 09 §8).
5. Micro-interactions
- FAB hide/show on scroll (grid): hide on scroll-down, show on scroll-up (03 §D).
- Slot drag ghost (desktop): card lifts to
e-4+ 0.95 scale while dragged; drop snaps into cell (08 §2); drop → editor opensm-base(create semantics per 09 §5). - Save button: label → spinner → check + snackbar "Slot added"; disabled while pending (anti-double-submit 08 §6).
- Week navigator:
‹/›+ cross-fade; "Today" resets offset. - Scope switch: segmented control; grid cross-fades
m-fastwhile refetching. - Conflict banner: auto-announce via live region; dismiss returns focus to conflicting field.
6. Frustration controls
- Every interactive element responds ≤ 100 ms (08 §6).
- Network-bound actions always pending-visible — never silent.
- Submit double-tap prevented; destructive actions
(planned)require explicit confirm dialog (typed confirm not required — soft-delete is reversible server-side; 05_Global_Information_Architecture.md §5 applies to irreversible purge only). - Grid never blocks input during refetch: stale requests dropped via request generation token (13 §5).
11 — Design System Mapping (Timetable Module)
- 1. Screen → M3 component map
- 2. Conflict / slot color semantics (module canon)
- 3. Typography
- 4. Spacing & layout tokens
- 5. Elevation
- 6. Icons (Material Symbols outlined; filled for status — 02 §7)
- 7. Module-level theming rules
Extends 00-shared/04 with module components. Every widget → token → Flutter mapping.
1. Screen → M3 component map
| Screen | Primary widgets | M3 roles |
|---|---|---|
| S1/S2 grid | TimetableGrid, TimetableSlotCard, DayHeader, TimeGutter, WeekNavigator, FAB | surfaceContainerLow cells, surface gutters, primaryContainer drop-target |
| S3 room view | TimetableGrid (read-only), AppSectionHeader | surfaceContainerLow |
| S4 editor | AppBottomSheet/AppDialog, pickers, TimePicker, FilledButton | surfaceContainerHigh, fill surfaceVariant, focus primary, error error |
| S5 conflict | ConflictBanner | errorContainer/onErrorContainer |
| S6 week nav | WeekNavigator, text buttons | secondaryContainer "Today" |
2. Conflict / slot color semantics (module canon)
| State | Token pair | Rationale |
|---|---|---|
| Slot (normal) | surfaceContainerLow/onSurface, e-1 | neutral card |
| Slot (hover, desktop) | surfaceContainerHigh, e-2 | affordance |
| Slot (conflict — client-derived same-grid overlap) | errorContainer/onErrorContainer border + event_busy icon + label | error role (09 §9 color-blind rule) |
| Drop target (valid) | primaryContainer/onPrimaryContainer | action = primary |
| Drop target (invalid) | errorContainer outline | rejected |
| Conflict banner | errorContainer/onErrorContainer | 02 §1 |
Always paired with icon + text — never color-only.
3. Typography
| Use | Token | Widget |
|---|---|---|
| Screen title | headlineMedium | grid AppBar |
| Slot subject | titleSmall | TimetableSlotCard |
| Slot teacher · room | bodySmall | TimetableSlotCard |
| Times (gutter, slot, editor) | mono (FontFeature.tabularFigures, 02 §2) | TimeGutter, cards, time fields |
| Day headers | labelLarge | DayHeader |
| Conflict text | labelMedium | ConflictBanner |
| Empty/error copy | bodyMedium | AppEmptyState/AppErrorState |
4. Spacing & layout tokens
| Token | Value | Use |
|---|---|---|
| Grid cell min | 96 × 48 dp (09 §5 touch targets) | grid cells |
| Time gutter | 48 dp fixed | TimeGutter |
| Day header | 48 dp | DayHeader |
| Card padding | 12 | TimetableSlotCard |
| Sheet corner | r-xl 16 top (phone) | S4 |
| Dialog corner | r-lg 12 | S4 desktop |
| Screen padding | 16 / 24 (tablet+) (04 §6) | grids |
5. Elevation
| Component | Token |
|---|---|
| Grid cells / slot cards | e-0 / e-1 |
| Slot hover (desktop) | e-2 |
| Drag ghost (desktop) | e-4 |
| S4 sheet / dialog | e-5 / e-4 |
| FAB | e-3 |
6. Icons (Material Symbols outlined; filled for status — 02 §7)
| Icon | Use |
|---|---|
calendar_month | timetable nav destination, empty state |
add | FAB "Add slot" |
event_busy | conflict badge + banner |
meeting_room | room chip on slot cards |
schedule | time gutter icon (proposed) |
chevron_left/right | WeekNavigator |
today | "Today" button |
person / groups | teacher/class scope icons |
content_copy | Duplicate slot action |
edit / delete | slot actions (planned) |
All icons have tooltip or Semantics.label (09 §7).
7. Module-level theming rules
- Conflict styling ONLY via
TimetableSlotCardconflict state +ConflictBanner— single source of truth. - Times always
mono+tabularFigures— scannable schedule reading. - Destructive actions
(planned)alwayserrortoken — never custom red. - Day order fixed Mon→Sat (
timetable.schema.ts:7-14) — no Sunday slot; a Sunday column must never be rendered. - Drop-target affordance only on desktop (drag exists there); mobile long-press never shows drag affordances (10 §1).
12 — API Mapping (Timetable Module)
- 0. Module-wide request envelope & client policy
- 1. Create — the only write
- 2. Reads — two scopes, no pagination
- 3. Supporting catalogs (read-only for this module)
- 4. Loading / streaming / realtime
- 5. Client error mapping table (module)
- 6. Pagination summary
- 7. Optimistic / undo
Exact wire contract per 00-shared/07. Base
/api/v1(main.ts:44-48); envelope{success,message,data,meta?,timestamp,requestId}. Global guards:RateLimitGuard → JwtAuthGuard → RbacGuard(app.module.ts:129-131); no timetable endpoint carries RBAC metadata (rbac.guard.ts:29→ JWT-only, 01 §5).BearerJWT;tenantIdfrom token only (base.repository.ts:33-35).
0. Module-wide request envelope & client policy
| Aspect | Contract |
|---|---|
| Headers | Authorization: Bearer; x-request-id client UUID; Content-Type: application/json |
| Tenancy | never in body; server injects tenantId + isDeleted:false scope (base.repository.ts:20-30) |
| Caching | catalogs (classes, subjects, teachers, rooms, years) 24 h TTL; timetable grids 5 min TTL (00-shared/06 §3.3) |
| Offline | reads last-good cache + banner; writes blocked (no offline queue) |
| Retry | backoff on 5xx/network; no auto-retry on 429 |
| Idempotency | create is not idempotent — no duplicate guard on identical slots (class-level dupes legal, OQ-1); Idempotency-Key optional (B6 shared ledger) |
1. Create — the only write
POST /timetable — create entry (S4)
- Body:
CreateTimetableEntryDto(create-timetable-entry.dto.ts:5-38, F1): exactlyclassId,subjectId,teacherId,roomId?,dayOfWeek(enum Mon–Sat,timetable.schema.ts:7-14),startTime,endTime,academicYearId. Unknown keys rejected (whitelist+forbidNonWhitelisted,main.ts:50-57). - 200
data: TimetableEntry(raw doc, refs as ObjectIds; timestamps auto,timetable.schema.ts:16). - 409
ConflictException"Schedule conflict detected" (timetable.service.ts:28): same-day existing entry sharesteacherIdorroomIdwith overlapping[startTime,endTime)(timetable.service.ts:17-22,62-69). No structured error code — client matches status. - 400 validation (invalid ids,
sundaydayOfWeek, unknown keys). - Side-effects:
TimetableEntryCreatedemitted (timetable.service.ts:33-44, payload{entryId, classId, teacherId}) — no queue routing found (01 §6).
2. Reads — two scopes, no pagination
GET /timetable?classId=<id> — class grid (S1)
classIdtakes precedence when both params present (timetable.controller.ts:26-27).- Success:
data: TimetableEntry[]— non-paginated array, nometa. - Sort: server-side
{dayOfWeek:1, startTime:1}(timetable.service.ts:48-53).
GET /timetable?teacherId=<id> — teacher grid (S2)
- Same shape; sort
{dayOfWeek:1, startTime:1}(timetable.service.ts:55-60).
GET /timetable — bare (no params)
- Returns
[](timetable.controller.ts:28) — no "all entries" fetch; client always scopes (OQ-4).
Missing surfaces (derived)
- No
?roomId=filter (timetable.controller.ts:22-25) — room view is a client composition (03 §J5); native filter(planned). - No
:idroutes, noPATCH, noDELETE(timetable.controller.ts:10) — update/delete(planned)(IMPLEMENTATION_PLAN.md:226). - No pagination, filters, or date params — grid pages are the whole week by model
(weekday + time only,
timetable.schema.ts:30-37).
3. Supporting catalogs (read-only for this module)
| Endpoint | Used by | Source |
|---|---|---|
GET /classes (+ by-year/:academicYearId) | S1 class picker, F1 classId | class.controller.ts:27-33 |
GET /subjects (paginated) | F1 subject picker, slot labels | subject.controller.ts:27-29 |
GET /teachers (paginated) | F1 teacher picker, slot labels | teacher.controller.ts:27-29 |
GET /rooms (paginated, ?page=&limit=) | F1 room picker, room labels | rooms.controller.ts:30-34 |
GET /academic-years | F1 year picker (isCurrent) | academic-year.controller.ts:27-30 |
GET /dashboard/overview | timetable-related KPIs (proposed) | dashboard module |
4. Loading / streaming / realtime
| Screen | Loading | Realtime |
|---|---|---|
| S1/S2/S3 grid | skeleton grid | — (timetable WS events (planned); TimetableEntryCreated unrouted today, 01 §6) |
| S4 editor | submit spinner only | — |
| S5 banner | — | appears on 409 / pre-flight |
5. Client error mapping table (module)
| Screen | Code | UI |
|---|---|---|
| all | 401 | silent refresh; fail → sessionExpired |
| all | 403 | 403 screen (future server enforcement; client-side perm gate today) |
| grid | 404 (catalog ref gone) | slot shows "—" |
| S4 | 409 | ConflictBanner, keep form |
| S4 | 400 | per-field errors |
| all | 429 | "Try again in a moment", no auto-retry |
| all | 5xx | generic + requestId + retry |
6. Pagination summary
GET /timetable: non-paginated array — one request = whole weekly grid per scope. Large-school caveat: verify payload size; server pagination(planned)(IMPLEMENTATION_PLAN.md:226).- Catalogs paginate via
PaginationQueryDto— client loads withlimit=100and caches 24 h.
7. Optimistic / undo
- Grid refresh + editor create: server-first (read-after-write; insert slot only after 200).
- No destructive op exists today; delete
(planned)will follow 00-shared/06 §3.5 (confirm dialog; soft-delete viaBaseRepositorypattern —base.repository.ts:68-74). - Drag & drop opens the editor (create semantics) — never an optimistic move (09 §5).
13 — State Management (Timetable Module)
- 1. Cubits
- 2. State classes
- 3. Repository & cache keys
- 4. Events
- 5. Cubit flow (grid + editor)
- 6. Realtime / cross-cubit
- 7. Optimistic policy (module)
- 8. Error handling
- 9. Testing hooks
Extends 00-shared/06. Bloc/Cubit, one cubit per screen; repository layer only place touching HTTP; typed
ApiException(code, status, fieldDetails).
1. Cubits
| Cubit | Screen | State | Notes |
|---|---|---|---|
TimetableGridCubit | S1/S2 | TimetableGridState | scope (class/teacher), week offset, entries |
EntryFormCubit | S4 | EntryFormState | F1 fields + conflict handling |
RoomGridCubit | S3 (planned) | RoomGridState | merge of class responses |
WeekNavCubit | S6 | WeekNavState | offset only, shell-level (forward-looking) |
2. State classes
// S1/S2
class TimetableGridState {
LoadState load; // Initial | Loading | Success | Error
GridScope scope; // class(scopeId) | teacher(scopeId)
int weekOffset; // 0 = current week (client-only concept)
List<TimetableEntry> entries; // server-sorted dayOfWeek, startTime
List<TimetableEntry> conflicts; // client-derived same-grid overlaps (OQ-1)
int requestToken; // drop stale scope-switch responses
}
// S4
class EntryFormState {
LoadState submit; // idle | submitting | success | error
EntryFormData data; // field DTO mirror (F1)
Map<String, String> fieldErrors; // from 400 details
ConflictClash? clash; // teacher | room, from 409 + pre-flight
bool dirty;
}
// S3 (planned)
class RoomGridState {
LoadState load;
String roomId;
List<TimetableEntry> merged; // composed from per-class fetches
Map<String, LoadState> perClass; // per-source load status
}
3. Repository & cache keys
class TimetableRepository {
Future<List<TimetableEntry>> byClass(String classId); // GET /timetable?classId=
Future<List<TimetableEntry>> byTeacher(String teacherId); // GET /timetable?teacherId=
Future<TimetableEntry> create(EntryDraft dto); // POST /timetable
}
| Cache key | TTL | Policy |
|---|---|---|
sl:{tenant}:timetable:class:{classId} | 5 min | stale-while-revalidate (06 §3.3) |
sl:{tenant}:timetable:teacher:{teacherId} | 5 min | stale-while-revalidate |
sl:{tenant}:timetable:refs:{classes|subjects|teachers|rooms|years} | 24 h | reference data |
| detail / editor | none | server-fresh on every submit |
RefreshIndicator always bypasses cache (06 §3.3).
4. Events
LoadGrid(scope),ChangeScope(scope),ChangeWeek(offset),RefreshGrid,GridCellTap(slot),SlotTap(entry)SubmitEntry(draft),RetrySubmit,DismissConflict,DiscardPreflightChange(field, value)— runs local teacher/room clash check- Naming per
00-shared/06 §4.
5. Cubit flow (grid + editor)
flowchart TD
A[TimetableGridCubit] -->|LoadGrid| B[Repository.byClass/byTeacher]
B --> C[Success: entries → cells\n server-sorted, no re-sort]
C --> D[EntryFormCubit.SubmitEntry]
D --> E{Pre-flight clash?}
E -->|warn| F[ConflictBanner advisory]
E -->|none| G[POST /timetable]
G -->|409| H[clash set → ConflictBanner]
G -->|200| I[emit GridSlotAdded]
I --> J[TimetableGridCubit inserts slot\n AnimatedList m-base]
H --> K[keep form values]
6. Realtime / cross-cubit
notification.new(WS,00-shared/07 §8) for own tenant → grid marks refresh-needed badge(planned); no live mutation (TimetableEntryCreatedis emitted but unrouted —01 §6).- Teachers module
ScheduleTabCubitsharesbyTeachercache — refetch on grid mutation while tab visible. - Dashboard KPIs
(proposed): refresh onTimetableEntryCreatedwhen dashboard visible.
7. Optimistic policy (module)
| Action | Optimistic? |
|---|---|
| Scope switch, week offset | yes (local state) |
| Grid cell/slot taps | yes (local) |
| Entry create | no — server-confirmed; insert after 200 only |
| Conflict banner | yes (local pre-flight) but always server-verifiable |
Delete (planned) | no (soft-delete confirm, 00-shared/06 §3.5) |
8. Error handling
- 409 →
ConflictClashfrom pre-flight data + server status (server sends only "Schedule conflict detected",timetable.service.ts:28); form values kept. - 400 →
fieldErrorsfromerror.details(00-shared/07 §3). - 404 → grid treats ref as "—" (catalog gone); entries themselves are only deleted by planned routes.
- 401 →
AuthCubitrefresh; fail → session expiry. - 5xx →
AppErrorStatewith requestId; retry re-emits event.
9. Testing hooks
TimetableGridCubitpure-Dart unit tests (mockTimetableRepository): scope switch, week offset, server-sort preservation, conflict derivation.EntryFormCubit: 409 mapping, fieldErrors, time-format guard (08 F1).- Widget tests: grid 3-state (loading/error/empty), S4 409-banner, S5 banner
announce,
TimetableSlotCardgolden ×2×2. AppStateObservertransition logs dev-only (06 §6).
14 — QA Checklist (Timetable Module)
- 1. Conflict & overlap matrix (the core rule)
- 2. Time / day validation
- 3. Read paths & sorting
- 4. Mutation lifecycle (or lack of it)
- 5. Tenant isolation & security
- 6. Event/side-effect correctness [backend]
- 7. Client QA (per 00-shared/10)
- 8. Release gates (module)
Module-specific additions to 00-shared/10. Items marked [backend] are bugs or gaps derivable from source and should be raised with the API team, not papered over in the UI.
1. Conflict & overlap matrix (the core rule)
| # | Scenario | Expected | Server reality |
|---|---|---|---|
| QA-1 | Same teacher, same day, overlapping time | 409 "Schedule conflict detected" | ✅ $or clause {teacherId, dayOfWeek} + timeOverlaps (timetable.service.ts:17-22,62-69) |
| QA-2 | Same room, same day, overlapping time (both entries have roomId) | 409 | ✅ {roomId, dayOfWeek} clause (timetable.service.ts:20) |
| QA-3 | Same room, same day, overlapping — one entry has no roomId | No conflict (different rooms) | ✅ roomId: undefined is stripped from the query → clause no-ops (timetable.service.ts:20) — verify with E2E |
| QA-4 | Same class, same day, overlapping time (two subjects) | Client highlights only; server accepts | ❌ No classId check (timetable.service.ts:17-22) — [backend] confirm intent (OQ-1); UI must not block |
| QA-5 | Back-to-back: entry A ends 09:00, entry B starts 09:00 | Allowed | ✅ start1 < end2 && start2 < end1 → false (timetable.service.ts:68) |
| QA-6 | Same teacher, different day, same time | Allowed | ✅ day filter (timetable.service.ts:19-20) |
| QA-7 | Boundary: start1 == start2 | Conflict | ✅ overlap true |
| QA-8 | Contained: A 08:00–10:00 vs B 08:30–09:00 | Conflict | ✅ overlap true |
| QA-9 | Two admins race the same slot | Second gets 409 (no corruption) | ✅ create-time check; client pre-flight is advisory |
| QA-10 | Non-zero-padded time ("8:00") vs "09:00" | Broken math (lexical: "8:00" > "09:00") | ❌ @IsString only (create-timetable-entry.dto.ts:27-33) — [backend] add HH:MM pattern; client picker-only input (proposed) |
2. Time / day validation
| # | Scenario | Expected |
|---|---|---|
| QA-11 | dayOfWeek: "sunday" | 400 — @IsEnum(DayOfWeek) (create-timetable-entry.dto.ts:23-25); saturday legal, sunday not (timetable.schema.ts:7-14) |
| QA-12 | endTime <= startTime | Server accepts (no order check) — client blocks pre-submit (proposed); [backend] consider validation |
| QA-13 | Malformed time "9:00 am" | Server accepts (@IsString) — client picker prevents; grid math would be wrong if sent |
| QA-14 | Unknown body key (e.g. tenantId) | 400 — forbidNonWhitelisted (main.ts:50-57) — client never sends tenantId |
3. Read paths & sorting
| # | Scenario | Expected |
|---|---|---|
| QA-15 | GET /timetable?classId= | [] or entries sorted dayOfWeek, startTime (timetable.service.ts:48-53) — client renders as-is, no re-sort |
| QA-16 | GET /timetable?teacherId= | Same sort contract (timetable.service.ts:55-60) |
| QA-17 | Both classId + teacherId | classId wins (timetable.controller.ts:26-27) — client never sends both |
| QA-18 | Bare GET /timetable | [] (timetable.controller.ts:28) — never present as "all entries" |
| QA-19 | ?roomId= filter | 404-ish/ignored — param doesn't exist (timetable.controller.ts:22-25) — room view is client composition; native filter (planned); [backend] |
| QA-20 | Sort with equal dayOfWeek + startTime (duplicate slots) | Insertion order — verify grid handles ties (no flicker) |
4. Mutation lifecycle (or lack of it)
| # | Scenario | Expected |
|---|---|---|
| QA-21 | PATCH /timetable/:id | No endpoint (timetable.controller.ts:10) — editor offers Duplicate+create only; edit UI (planned) — [backend] (OQ-2) |
| QA-22 | DELETE /timetable/:id | No endpoint — soft-delete columns exist on BaseSchema but are unused by the module; delete (planned) |
| QA-23 | Teacher soft-deleted with live timetable entries | Entries remain (no cascade — no delete path; teacher ref dangles) — grid shows "—" name; [backend] consider cascade/substitution (IMPLEMENTATION_PLAN.md:226) |
| QA-24 | Room deleted with live entries | Entries remain; room chip drops (roomId ref unresolved → "—") |
| QA-25 | Entry created for soft-deleted teacher/class/room | Server performs no existence checks on create — only the conflict query runs (timetable.service.ts:16-30) — pickers must only offer live records; [backend] |
| QA-26 | Duplicate identical entry (same everything, same class) | Accepted (no unique index, no dup check) — by design for classes (OQ-1); verify UX intent |
5. Tenant isolation & security
| # | Scenario | Expected |
|---|---|---|
| QA-27 | Cross-tenant entry id in create/read | 404/scope-excluded (base.repository.ts:20-30) — never leak existence (00-shared/07 §3) |
| QA-28 | No tenantId in bodies | Client never sends it (07 §6) |
| QA-29 | Unauthenticated call | 401 → refresh flow |
| QA-30 | RBAC: endpoints carry no timetable.read/create metadata despite perms existing | permissions.constants.ts:44-45 vs timetable.controller.ts:9 — client-side gate only; [backend] add metadata |
6. Event/side-effect correctness [backend]
| # | Item | Evidence |
|---|---|---|
| QA-31 | TimetableEntryCreated emitted but no queue mapping found (event-queue-map.ts) | timetable.service.ts:33-44 — confirm intended consumers (notification/audit/search) or drop emission |
7. Client QA (per 00-shared/10)
| # | Item |
|---|---|
| QA-32 | Grid perf: 6×~10 grid with ≥ 40 slot cards ≤ 60 fps on low-end; lazy row build + RepaintBoundary (15 §9) |
| QA-33 | A11y: slot labels complete (subject/teacher/room/day/time), conflict announced live, Sunday column absent |
| QA-34 | Text scale 2×: slot cards don't clip (subject shortName truncation) |
| QA-35 | Dark mode parity: conflict tokens verified both modes |
| QA-36 | Offline: grid from cache + banner; editor blocked |
| QA-37 | Week nav: offset only re-filters (no server date) — Today resets without refetch |
| QA-38 | Desktop drag: valid/invalid drop targets, no optimistic persistence (10 §2) |
| QA-39 | Analytics (proposed): timetable.entry.{submit,conflict,success}, timetable.class.view (05) |
| QA-40 | Golden tests: TimetableSlotCard ×2×2, ConflictBanner, WeekNavigator |
8. Release gates (module)
-
Backend gaps QA-4/10/12/19/21/22/23/25/30/31 triaged (none block v1 read UI;
editor ships with client-side guards +
(planned)markers) - All envelope error codes map to i18n keys (no raw 5xx text)
- E2E: create entry → conflict 409 → fix → grid shows slot; cross-tenant 404
-
E2E: bare
GET /timetable→[]path never triggered by UI
15 — Flutter Implementation Guide (Timetable Module)
- 1. Module folder
- 2. Domain models
- 3. Repository
- 4. Cubits
- 5. Router
- 6. Theme & components
- 7. i18n keys
- 8. Drag & drop (desktop) & long-press (mobile)
- 9. Grid table performance (hero component)
- 10. Tests
- 11. Known backend gaps to coordinate (from 14-QA)
Extends 00-shared/11. Forward-looking: no client repo exists (shared ledger A1). Everything below derives from
src/modules/timetable/**,src/modules/academics/**,src/modules/teachers/**,src/modules/rooms/**,src/modules/rbac/**(perms atpermissions.constants.ts:44-45).
1. Module folder
lib/features/timetable/
├── data/
│ ├── dto/timetable_entry_dto.dart # envelope payload mapper
│ ├── dto/entry_draft_dto.dart # create payload (F1)
│ ├── models/timetable_entry.dart # + DayOfWeek enum (6 values)
│ └── repositories/timetable_repository.dart
├── domain/
│ └── timetable_service.dart # grid assembly + conflict derivation
└── presentation/
├── cubit/timetable_grid_cubit.dart
├── cubit/entry_form_cubit.dart
├── pages/timetable_grid_page.dart
├── widgets/timetable_grid.dart, timetable_slot_card.dart,
│ day_header.dart, time_gutter.dart, grid_cell.dart,
│ conflict_banner.dart, week_navigator.dart, entry_editor_sheet.dart
2. Domain models
enum DayOfWeek { monday, tuesday, wednesday, thursday, friday, saturday }
// parse via EnumByName; sunday → parse error → model invalid (server enum, timetable.schema.ts:7-14)
class TimetableEntry {
final String id, classId, subjectId, teacherId;
final String? roomId;
final DayOfWeek dayOfWeek;
final String startTime, endTime; // zero-padded "HH:MM"
final String academicYearId;
}
- DTO→model: strict
fromJson; times kept as strings (server contract —DateTimeparse is display-only, never re-serialized;08 F1). - Ref-name resolution:
TimetableService.joinNames(entry, catalogs)— catalogs (classes/subjects/teachers/rooms/years) loaded once, cached 24 h (13 §3).
3. Repository
class TimetableRepository {
TimetableRepository(this._dio); // AppDio (00-shared/11 §5)
Future<List<TimetableEntry>> byClass(String classId); // GET /timetable?classId=
Future<List<TimetableEntry>> byTeacher(String teacherId); // GET /timetable?teacherId=
Future<TimetableEntry> create(EntryDraftDto dto); // POST /timetable
}
- Errors: interceptor maps envelope →
ApiException(code, status, fieldDetails)(00-shared/11 §5); 409 exposes aconflictflavor (server message is generic — map via pre-flight data,06 §S4). - Always send exactly one query param (both → classId wins; none →
[];timetable.controller.ts:26-28).
4. Cubits
TimetableGridCubit— scope + weekOffset + entries; never re-sorts (server contractdayOfWeek, startTime,timetable.service.ts:48-60); derivesconflictslist (same-grid teacher/room overlaps) for slot badges (OQ-1).EntryFormCubit— mirrorsEntryDraftDto; time-format guard (24 hHH:MM,end > start); pre-flight teacher/room clash check against cached grids; on 409 keeps form + setsclash.RoomGridCubit(planned)— mergesbyClassresponses per room usage.WeekNavCubit— offset only (client concept; no server date dimension).
5. Router
// go_router additions (00-shared/05 §4 + 00-shared/11 §6)
GoRoute(path: '/academics/timetable', builder: TimetableGridPage.new), // ?classId=
GoRoute(path: '/academics/timetable/teacher/:teacherId', builder: TimetableGridPage.new),
// (planned) room view + edit/delete surfaces
GoRoute(path: '/academics/timetable/room/:roomId', builder: RoomGridPage.new),
GoRoute(path: '/my/schedule', builder: MySchedulePage.new), // teacher self
- Guards:
permissionGuard('timetable.read')/('timetable.create')client-side mirror ofpermissions.constants.ts:44-45(server is JWT-only today —01 §5). - Deep link:
studylyon://timetable?classId=:id.
6. Theme & components
- All tokens via
AppTheme(00-shared/04); module components in07; conflict colors only insideTimetableSlotCardconflict state +ConflictBanner(11 §7). - Times rendered
mono+tabularFigures(02 §2) in gutter, cards, editor.
7. i18n keys
timetable.title, timetable.class, timetable.teacher, timetable.room,
timetable.scope.{class,teacher,room}, timetable.addSlot, timetable.slot.detail,
timetable.slot.duplicate, timetable.slot.edit, timetable.slot.delete, // edit/delete (planned)
timetable.empty.{class,teacher,room}, timetable.conflict.teacher,
timetable.conflict.room, timetable.conflict.preflight, timetable.slot.doubleBooked,
timetable.saved, timetable.week.{prev,next,today}, timetable.drag.copy,
timetable.day.{monday,tuesday,wednesday,thursday,friday,saturday},
timetable.field.{class,subject,teacher,room,day,startTime,endTime,year},
errors.server, errors.rateLimited
8. Drag & drop (desktop) & long-press (mobile)
- Desktop:
TimetableSlotCardwrapped inDraggable<TimetableEntry>(feedback = card ate-4+ 0.95 scale);GridCellwrapsDragTarget<TimetableEntry>:onAcceptWithDetails→ openEntryEditorSheetprefilled (day/time from target cell, subject/teacher/room from dragged entry) — create semantics, no PATCH (10 §2,09 §5; copy banner "This creates a new slot; original stays until delete(planned)"). - Mobile: no drag — long-press opens the slot popover (
10 §1);LongPressDraggableis deliberately unused on touch platforms. - Invalid target (occupied cell): reject +
errorContaineroutline; return animationm-basespring.
9. Grid table performance (hero component)
| Technique | Why |
|---|---|
Lazy rows: ListView.builder (or CustomScrollView) over day columns; cells built on demand | 6×N grid with ≥ 40 slots — no eager 60-widget build |
Slot cards wrapped in RepaintBoundary | drag/hover repaints don't relayout the whole grid |
TimeGutter + DayHeader pinned via sticky headers, single shared ScrollController | constant header during horizontal/vertical scroll |
Cache-extent tuning + const where possible | jank-free 60 fps on low-end (14 QA-32) |
Semantics merged per slot (one node) | a11y tree stays small |
Fonts: mono + tabularFigures times | no width jitter while scrolling |
- Grid assembly:
Map<DayOfWeek, List<TimetableEntry>>from the server-sorted list (O(n) pass); row keys = ascending union ofstartTime; slot height =(end−start) / periodMinutes × rowHeight, min 1 row.
10. Tests
| Layer | Cases |
|---|---|
| Unit | TimetableGridCubit scope/week/conflict-derivation; EntryFormCubit 409 mapping, time-format guard (08 F1), pre-flight clash; DTO↔model mappers (sunday → invalid) |
| Widget | grid 3-state (skeleton/error/empty), S4 409-banner, TimetableSlotCard golden ×2×2 (normal/conflict × light/dark), drop-target accept/reject |
| Integration | create → 409 → fix → grid shows slot; scope switch refetch; week nav no-refetch |
| E2E | P0: full create/conflict journey + cross-tenant 404 + bare GET /timetable never sent (per 00-shared/10 §9) |
11. Known backend gaps to coordinate (from 14-QA)
-
Class-overlap check or confirmed intent (QA-4/OQ-1); time
HH:MMpattern +end > startvalidation (QA-10/12); room filter?roomId=(QA-19) - Update/delete endpoints (QA-21/22), cascade/substitution for deleted teachers (QA-23), existence checks on create (QA-25)
-
RBAC metadata on endpoints (QA-30);
TimetableEntryCreatedrouting (QA-31)
01 - Product Overview (Transport Module)
StudyLyon - multi-tenant ERP / School Management API. This package designs the Transport module client (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, domain events, permissions and wire contracts are derived directly from
src/modules/transport/**,src/modules/students/**,src/modules/rbac/permissions.constants.ts,docs/IMPLEMENTATION_PLAN.mdanddocs/user-flows/END_TO_END_USER_FLOWS.md. No feature is invented - anything not present in source is flagged(planned)/(proposed)/(forward-looking).
Heads-up: per the PRD, the mobile client is out of Phase 1 scope
(PRODUCT_REQUIREMENTS_DOCUMENT.md:144, flagged in 00-shared/12 A1); this package
is the forward-looking spec the client will be built against later.
1. Purpose
Transport manages the school fleet and daily movement of students:
- Vehicles - fleet register (plate, model, capacity, type, status).
- Drivers - licensed staff register (license, phone, contact details).
- Routes - fixed paths with ordered stops, assigned vehicle + driver.
- Assignments - which student rides which route, on which shift, from which stop.
Everything is tenant-scoped (tenantId on every document) and soft-delete capable;
the repository layer injects both scopes into every query
(base.repository.ts:20-30). All endpoints are JWT-guarded
(transport.controller.ts:23-26).
| Responsibility | Source |
|---|---|
| Vehicle CRUD + soft delete | transport.controller.ts:30-58, transport.service.ts:37-110 |
| Route CRUD + soft delete | transport.controller.ts:60-88, transport.service.ts:112-174 |
| Driver CRUD + soft delete | transport.controller.ts:90-118, transport.service.ts:176-249 |
| Student <-> route assignment | transport.controller.ts:120-136, transport.service.ts:251-291 |
| Duplicate guards (plate/license/phone/route name/assignment) | transport.service.ts:38-45, 113-116, 177-190, 252-258 |
| Delete guards (in-use vehicle/driver/route) | transport.service.ts:94-99, 168-171, 241-246 |
| Unique compound indexes | vehicle.schema.ts:50, driver.schema.ts:50-51, route.schema.ts:44, route-assignment.schema.ts:42-46 |
| Tenant scoping + soft-delete on every query | base.repository.ts:20-30 |
| Student transport requirement flag | student.schema.ts:53-54 |
| RBAC permissions | permissions.constants.ts:62-74 |
2. Business goals
| Goal | Measure |
|---|---|
| No duplicate vehicles | unique {tenantId, plateNumber} + service 409 (vehicle.schema.ts:50, transport.service.ts:38-45) |
| No duplicate drivers | unique {tenantId, licenseNumber} and {tenantId, phone} + service 409 (driver.schema.ts:50-51, transport.service.ts:177-190) |
| No duplicate routes | unique {tenantId, name} + service 409 (route.schema.ts:44, transport.service.ts:113-116) |
| One assignment per (student, route) | unique {tenantId, routeId, studentId} + service 409 (route-assignment.schema.ts:42-45, transport.service.ts:252-258) |
| Referential integrity on delete | in-use vehicle/driver/route cannot be deleted (409) (transport.service.ts:94-99, 168-171, 241-246) |
| Cross-tenant isolation | every query tenant-scoped via BaseRepository.scopedFilter (base.repository.ts:20-30) |
| Audit trail | VehicleCreated / VehicleDeleted / RouteCreated / StudentRouteAssigned events (transport.service.ts:47-57, 102-109, 121-128, 267-278) |
3. User goals
- Transport admin / school admin: keep the fleet and driver register accurate; define routes with stops; assign vehicles and drivers; assign students to routes and shifts; view a student's current route assignments.
- Parent: (forward-looking) see the child's route assignment and stop
(
docs/user-flows/END_TO_END_USER_FLOWS.md:392-406). - Student: (forward-looking) know which bus/stop/shift to use.
4. Scope
4.1 In scope (implemented backend)
Vehicle, driver and route CRUD with paginated lists (?page=1&limit=20 defaults,
transport.controller.ts:38, 68, 98); ordered stop arrays on routes; student-route
assignment with shift (morning/evening/both), stop and notes; per-student
assignment lookup with populated route; soft delete with conflict guards.
4.2 Planned (IMPLEMENTATION_PLAN.md:229 - "Live tracking, bus attendance, fee
calc, emergency", 6 days)
Live vehicle tracking, bus attendance, transport fee calculation, emergency
handling. Documented only as roadmap items; no backend exists - marked (planned)
throughout this package.
4.3 Forward-looking (client roadmap)
Parent-facing "bus tracking" read of a child's route (GET /transport/assignments/:studentId,
referenced in docs/user-flows/END_TO_END_USER_FLOWS.md:406), QR-based boarding
(forward-looking), push alerts (e.g. transport.delay notification type,
docs/user-flows/END_TO_END_USER_FLOWS.md:430).
4.4 Proposed (analytics)
Analytics events on screens (transport.*.*) per 00-shared/10 §8 - (proposed).
5. Non-goals (this version)
- Capacity enforcement at assignment time (no check in
assignStudent,transport.service.ts:251-266) - flagged as QA gap,(planned). - Search/filter beyond pagination (list queries filter
{},transport.service.ts:69, 141, 210). - Driver-vehicle pairing as first-class entity (only via route
vehicleId/driverId,route.schema.ts:26-30). - Assignment status change API (status exists on schema
route-assignment.schema.ts:23-28, no endpoint to flip it; only soft delete viaDELETE /assignments/:id).
02 - User Personas (Transport Module)
- 1. Transport Admin (
transport.*full access) - 2. School Admin / Registrar
- 3. Parent (forward-looking)
- 4. Driver (planned)
- 5. Student (planned)
- Permission matrix (source of truth:
permissions.constants.ts:62-74)
Personas for the Transport module client, derived from the module's permission surface (
permissions.constants.ts:62-74) and role matrix (docs/user-flows/END_TO_END_USER_FLOWS.md:760-763). All access is JWT-gated; RBAC guards on the actual endpoints are not yet implemented (seeAGENTS.md"Not yet implemented").
1. Transport Admin (transport.* full access)
Who: school operations staff who own the fleet and daily commute.
Permissions: transport.vehicle.*, transport.route.*, transport.driver.*,
transport.assign (read/create/update/delete + assign,
permissions.constants.ts:62-74).
Goals:
- Keep the fleet register truthful: no duplicate plates, current vehicle status.
- Keep routes stable and drivers licensed.
- Answer "which route does this student ride?" in seconds
(
GET /transport/assignments/:studentId,transport.controller.ts:126-130). Pain points: duplicate-plate or duplicate-license entries silently created in other systems; deleting a vehicle that is still on a route (backend 409s,transport.service.ts:94-99); no search - paginated lists only (transport.service.ts:69). Frustration threshold: high - operates daily; bulk operations matter.
2. School Admin / Registrar
Who: owns student records including transportRequired flag
(student.schema.ts:53-54).
Goals: ensure students who need transport are assigned to a route and shift;
hand off to transport admin when a student transfers.
Permissions: same transport.* set (role matrix
docs/user-flows/END_TO_END_USER_FLOWS.md:760-763 grants transport to admin).
Pain points: assignment conflicts (student already on the route - 409,
transport.service.ts:252-258).
3. Parent (forward-looking)
Who: wants to know where the child's bus is and when it arrives.
Goals: read the child's route assignment and stop
(GET /transport/assignments/:childId, docs/user-flows/END_TO_END_USER_FLOWS.md:392-406).
Permissions: read-own only; transport.* not granted to parents
(docs/user-flows/END_TO_END_USER_FLOWS.md:760-763). No parent-facing transport
endpoint exists - (forward-looking).
4. Driver (planned)
Who: fleet driver; will see assigned route, vehicle, and stop list
((planned) - driver app surfaces from IMPLEMENTATION_PLAN.md:229 scope).
Goals: know today's route, stops in order, students on board ((planned)),
report delays/emergencies ((planned)).
5. Student (planned)
Who: rider; sees which bus/stop/shift applies. (planned).
Permission matrix (source of truth: permissions.constants.ts:62-74)
| Permission | Admin | Registrar | Parent | Driver |
|---|---|---|---|---|
transport.vehicle.read | yes | yes | no | no |
transport.vehicle.create/update/delete | yes | no | no | no |
transport.route.read | yes | yes | no | no |
transport.route.create/update/delete | yes | no | no | no |
transport.driver.read | yes | yes | no | no |
transport.driver.create/update/delete | yes | no | no | no |
transport.assign | yes | yes | no | no |
Matrix follows docs/user-flows/END_TO_END_USER_FLOWS.md:760-763
(transport.* = admin yes, parent read-own only, no driver/student row today).
03 - User Journeys (Transport Module)
- 1. Fleet onboarding: add vehicle + driver, define route, assign students
- 2. Daily ops: check fleet and route health
- 3. Find a student's route
- 4. Decommission (soft delete) with guards
- 5. Parent bus lookup (forward-looking)
- 6. Live tracking / bus attendance / fee calc (planned)
- Journey coverage summary
Primary journeys for the Transport module, mapped to exact endpoints. Each step cites the source. States/loading/error handling follow 00-shared/06 §3.
1. Fleet onboarding: add vehicle + driver, define route, assign students
Mirrors the setup flow in docs/user-flows/END_TO_END_USER_FLOWS.md:205-222.
- Add vehicle - form (plate, model, capacity >= 1, type) ->
POST /api/v1/transport/vehicles(transport.controller.ts:30-34).- 409 duplicate plate -> inline error (
transport.service.ts:38-45).
- 409 duplicate plate -> inline error (
- Add driver - form (first/last name, licenseNumber, phone, optional email,
licenseExpiry, address, emergencyContact, joinedAt, notes) ->
POST /api/v1/transport/drivers(transport.controller.ts:90-94).- 409 duplicate license OR duplicate phone (
transport.service.ts:177-190).
- 409 duplicate license OR duplicate phone (
- Create route - name, startPoint, endPoint, ordered stops, optional
vehicleId + driverId, estimatedDuration, notes ->
POST /api/v1/transport/routes(transport.controller.ts:60-64).- 409 duplicate name (
transport.service.ts:113-116).
- 409 duplicate name (
- Assign students - pick student + route + shift (
morning/evening/both) + optional stopName/notes ->POST /api/v1/transport/assign(transport.controller.ts:120-124).- 409 already-assigned (
transport.service.ts:252-258).
- 409 already-assigned (
Error states at each step: 401 (token) -> re-login; 409 -> inline conflict message;
429/offline -> AppOfflineBanner + retry (see 14_QA_Checklist.md §1).
2. Daily ops: check fleet and route health
- Open Transport Overview (
/transport/overview) - aggregates counts by callingGET /transport/vehicles,GET /transport/routes,GET /transport/drivers(transport.controller.ts:36-40, 66-70, 96-100). - Drill into vehicle detail (
GET /transport/vehicles/:id,transport.controller.ts:42-46), driver detail (GET /transport/drivers/:id,:102-106), route detail with stops (GET /transport/routes/:id,:72-76).
3. Find a student's route
- Search/select student (students module) ->
GET /transport/assignments/:studentId(transport.controller.ts:126-130) -> list of assignments with populated route (route-assignment.repository.ts:20-24). - Show route name, shift, stopName, status; empty state "No route assigned".
- Remove an assignment -> confirm dialog ->
DELETE /transport/assignments/:id(transport.controller.ts:132-136) -> item leaves list (optimistic, rollback on 404).
4. Decommission (soft delete) with guards
- Vehicle: delete -> if a route references it, 409 "Vehicle is assigned to a route."
(
transport.service.ts:94-99); else soft delete +VehicleDeletedevent (transport.service.ts:100-109). - Driver: same guard on route reference (
transport.service.ts:241-246). - Route: delete -> if students are assigned, 409 "Route has active student
assignments." (
transport.service.ts:168-171); else soft delete (transport.service.ts:172-173). - UI consequence: 409 -> explain the dependency, offer navigation to the blocking route list/detail instead of a generic error.
5. Parent bus lookup (forward-looking)
Parent opens child profile -> route assignment + stop (GET /transport/assignments/:childId
per docs/user-flows/END_TO_END_USER_FLOWS.md:392-406); live bus position,
arrival alerts (transport.delay, :430) and QR boarding (forward-looking).
6. Live tracking / bus attendance / fee calc (planned)
Per IMPLEMENTATION_PLAN.md:229: live tracking, bus attendance, fee calc, emergency.
No backend surface today - all steps (planned).
Journey coverage summary
| Journey | Endpoints | Module pages |
|---|---|---|
| 1 Fleet onboarding | vehicles/drivers/routes CRUD + assign | 06 §1-5, 08 all forms |
| 2 Daily ops | 3 list + 3 detail GETs | 06 §1-3, 05 |
| 3 Student route lookup | assignments GET/DELETE | 06 §5, 13 AssignmentCubit |
| 4 Decommission | DELETE x3 with 409 guards | 06 §1-3, 10 §3 |
| 5-6 Parent/live | none (planned) | 06 §6-7 |
04 - Information Architecture (Transport Module)
- 1. Sitemap
- 2. Navigation rules
- 3. Entity model (source-derived)
- 4. Relationship semantics
- 5. IA principles
IA of the Transport module client. Global shells/navigation per 00-shared/05; this file maps module pages and their data dependencies.
1. Sitemap
/transport Transport Overview (module hub)
/transport/vehicles Vehicle list
/transport/vehicles/:id Vehicle detail
/transport/vehicles/new Vehicle create form (sheet on tablet)
/transport/drivers Driver list
/transport/drivers/:id Driver detail
/transport/drivers/new Driver create form (sheet on tablet)
/transport/routes Route list
/transport/routes/:id Route detail
/transport/routes/:id/edit Route editor w/ stops (sheet on tablet)
/transport/routes/new Route create form
/transport/assign Assign student to route (sheet)
/transport/students/:studentId/transport Student route assignments
/transport/live Live tracking (planned)
/transport/attendance Bus attendance (planned)
/transport/fees Transport fee calc (planned)
(planned) routes exist only as placeholders per IMPLEMENTATION_PLAN.md:229.
2. Navigation rules
- Overview is the hub: three summary tiles (vehicles, drivers, routes) +
"Assign student" entry. Counts come from paginated list calls
(
transport.controller.ts:36-40, 66-70, 96-100) -meta.totalItems(buildPaginationMeta,transport.service.ts:75, 146, 215). - List -> detail via row tap; detail -> edit via FAB/action; lists are the only
read entry for entities (no global search endpoint exists;
00-shared/05global search is out of transport scope). - Route editor and assign flows open as bottom sheets on phone, centered dialogs
on tablet/desktop (per
00-shared/03AppBottomSheet).
3. Entity model (source-derived)
Vehicle (vehicles) Driver (drivers)
tenantId* tenantId*
plateNumber (unique/tenant) firstName, lastName
model, capacity (min 1) licenseNumber (unique/tenant)
type: bus|van|car phone (unique/tenant), email?
status: active|maintenance| status: active|inactive|on_leave
inactive licenseExpiry?, address?,
year?, color?, insuranceExpiry?, emergencyContact?, joinedAt?, notes?
notes?
vehicle.schema.ts:7-50 driver.schema.ts:7-51
TransportRoute (transport_routes) RouteAssignment (route_assignments)
tenantId* tenantId*
name (unique/tenant) routeId -> TransportRoute (required)
startPoint, endPoint studentId -> Student (required)
stops: [{name, order}] shift: morning|evening|both (required)
vehicleId -> Vehicle? status: active|inactive
driverId -> Driver? stopName?, assignedAt?, notes?
status: active|inactive unique (tenantId, routeId, studentId)
estimatedDuration?, notes? index (tenantId, studentId)
route.schema.ts:7-44 route-assignment.schema.ts:7-46
tenantId,isDeleted, timestamps,versioncome fromBaseSchemaand are never read from request bodies (AGENTS.mdconventions;base.repository.ts:20-36).
4. Relationship semantics
- Route -> Vehicle: optional single reference (
route.schema.ts:26-27); a vehicle can appear on multiple routes (no uniqueness on route.vehicleId) but cannot be deleted while referenced (transport.service.ts:94-99). - Route -> Driver: same pattern (
route.schema.ts:29-30;transport.service.ts:241-246). - Student -> RouteAssignment: 1..N (a student can be on multiple routes via
separate assignments; same pair blocked by unique index
route-assignment.schema.ts:42-45). - Assignment -> route populated on student lookup
(
route-assignment.repository.ts:20-24).
5. IA principles
- Entity-first IA: vehicle/driver/route lists are pure CRUD surfaces; assignment lives under students (student-centric) and is reachable from the overview.
- Deletion always routes through a confirm + dependency explanation (409 guards,
transport.service.ts:94-99, 168-171, 241-246). - Master-detail on tablet/desktop (
00-shared/05§2).
05 - Screen Inventory (Transport Module)
- Legend
- 1. Transport Overview (
/transport) - 2. Vehicles - List (
/transport/vehicles) - 3. Vehicles - Detail (
/transport/vehicles/:id) - 4. Drivers - List (
/transport/drivers) - 5. Drivers - Detail (
/transport/drivers/:id) - 6. Routes - List (
/transport/routes) - 7. Routes - Detail (
/transport/routes/:id) - 8. Route Editor w/ stops (
/transport/routes/:id/edit,/transport/routes/new) - 9. Assign Student to Route (
/transport/assign, bottom sheet) - 10. Student Route Assignments (
/transport/students/:studentId/transport) - 11. Live Tracking (
/transport/live)(planned) - 12. Bus Attendance (
/transport/attendance)(planned) - 13. Transport Fees (
/transport/fees)(planned) - Shared components used
- Analytics events (proposed)
- Keyboard, landscape, tablet, desktop
Every screen of the Transport module, its intent, route, composition, states and permissions. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics. Format mirrors auth/05.
Legend
States = idle / loading / success / empty / error(offline, 409 conflict, 404, 401) /
disabled / permission. Analytics events follow {module}.{screen}.{action}
(proposed; SDK open - 00-shared/10 §8).
1. Transport Overview (/transport)
| Field | Detail |
|---|---|
| Purpose | Hub: counts + quick entry to vehicles, drivers, routes, assignment |
| Source | GET /transport/vehicles, GET /transport/routes, GET /transport/drivers (page=1, limit=1 cheapest) - transport.controller.ts:36-40, 66-70, 96-100; totals from meta.totalItems (transport.service.ts:75, 146, 215) |
| Widgets | 3 AppCard stat tiles (vehicles / routes / drivers) + AppListTile shortcuts + FAB "Assign student" |
| States | per-tile skeleton -> counts; error -> AppErrorState per tile |
| Permissions | transport.vehicle.read + transport.route.read + transport.driver.read; tile hidden when permission missing (permissions.constants.ts:62-74) |
| Analytics | transport.overview.open (proposed) |
2. Vehicles - List (/transport/vehicles)
| Field | Detail |
|---|---|
| Source | GET /transport/vehicles?page&limit (defaults 1, 20), sorted by plateNumber asc (transport.controller.ts:36-40, transport.service.ts:61-76) |
| Widgets | AppListTile per vehicle (plateNumber, model, capacity, status AppBadge), pagination footer, FAB "New vehicle" |
| Row action | tap -> detail; AppMenu -> Edit / Delete (confirm) |
| Empty | "No vehicles yet" + CTA create |
| Permissions | list = transport.vehicle.read; FAB/menu = .create / .update / .delete |
| Analytics | transport.vehicles.list (proposed) |
3. Vehicles - Detail (/transport/vehicles/:id)
| Field | Detail |
|---|---|
| Source | GET /transport/vehicles/:id (transport.controller.ts:42-46); 404 -> AppErrorState |
| Content | plateNumber, model, capacity, type (bus/van/car), status, year, color, insuranceExpiry, notes - vehicle.schema.ts:21-46 |
| Actions | Edit (.update), Delete with dependency guard (.delete); if route references vehicle -> 409 message + navigate to routes (transport.service.ts:94-99) |
4. Drivers - List (/transport/drivers)
| Field | Detail |
|---|---|
| Source | GET /transport/drivers?page&limit, sorted by firstName (transport.controller.ts:96-100, transport.service.ts:201-216) |
| Widgets | AppListTile (full name, licenseNumber, phone, status badge: active/inactive/on_leave), pagination, FAB "New driver" |
| Row action | detail; menu -> Edit / Delete |
| Empty | "No drivers yet" |
| Permissions | transport.driver.* (permissions.constants.ts:70-73) |
5. Drivers - Detail (/transport/drivers/:id)
| Field | Detail |
|---|---|
| Source | GET /transport/drivers/:id (transport.controller.ts:102-106) |
| Content | firstName, lastName, licenseNumber, phone, email, status, licenseExpiry, address, emergencyContact, joinedAt, notes - driver.schema.ts:15-46 |
| Delete guard | 409 when route references driver (transport.service.ts:241-246) |
6. Routes - List (/transport/routes)
| Field | Detail |
|---|---|
| Source | GET /transport/routes?page&limit, sorted by name (transport.controller.ts:66-70, transport.service.ts:132-147) |
| Widgets | AppListTile (name, startPoint -> endPoint, stop count, status badge), pagination, FAB "New route" |
| Row action | detail; menu -> Edit / Delete |
| Empty | "No routes yet" |
| Permissions | transport.route.* (permissions.constants.ts:66-69) |
7. Routes - Detail (/transport/routes/:id)
| Field | Detail |
|---|---|
| Source | GET /transport/routes/:id (transport.controller.ts:72-76) |
| Content | name, startPoint, endPoint, ordered stops list, vehicle + driver refs (names when resolved; ids are raw ObjectIds in payload - route.schema.ts:26-30), status, estimatedDuration, notes |
| Actions | Edit (route editor), Delete (409 if assignments exist, transport.service.ts:168-171), "Assign students" shortcut |
8. Route Editor w/ stops (/transport/routes/:id/edit, /transport/routes/new)
| Field | Detail |
|---|---|
| Purpose | Create/update route incl. ordered stop list |
| Form | name*, startPoint*, endPoint*, stops (reorderable {name, order} list), vehicleId picker, driverId picker, estimatedDuration, notes - create-route.dto.ts:21-60 |
| Stops | ReorderableListView; order field is client-set, sent as array of {name, order} (route.schema.ts:23-24) |
| Conflicts | 409 duplicate name on create (transport.service.ts:113-116); on update name change to existing -> 409 (unique index route.schema.ts:44) |
| Permissions | create = .route.create, update = .route.update |
9. Assign Student to Route (/transport/assign, bottom sheet)
| Field | Detail |
|---|---|
| Purpose | Create a route assignment for one student |
| Source | POST /transport/assign (transport.controller.ts:120-124) with AssignRouteDto (assign-route.dto.ts:4-26) |
| Form | route picker, student picker, shift (morning/evening/both), optional stopName, notes |
| Conflicts | 409 "Student already assigned to this route" (transport.service.ts:252-258) - inline, offer "view existing" |
| Note | student picker filtered to transportRequired students when available (student.schema.ts:53-54) |
10. Student Route Assignments (/transport/students/:studentId/transport)
| Field | Detail |
|---|---|
| Source | GET /transport/assignments/:studentId - assignments with populated route (transport.controller.ts:126-130, route-assignment.repository.ts:20-24) |
| Widgets | AppListTile per assignment (route name, shift badge, stopName, status), delete via DELETE /transport/assignments/:id (transport.controller.ts:132-136) |
| Empty | "No route assigned" + CTA assign |
| Permissions | transport.assign (read context) or transport.route.read |
11. Live Tracking (/transport/live) (planned)
Map of vehicles with routes + live positions. No backend surface
(IMPLEMENTATION_PLAN.md:229).
12. Bus Attendance (/transport/attendance) (planned)
Boarding list per route/shift with QR scan (forward-looking).
13. Transport Fees (/transport/fees) (planned)
Fee calculation per route/student (IMPLEMENTATION_PLAN.md:229).
Shared components used
AppTextField, AppButton, AppSnackbar, AppCard, AppListTile, AppBottomSheet,
AppDialog, AppMenu, AppSkeleton, AppEmptyState, AppOfflineBanner, AppFAB,
AppChips, AppBadge, AppAvatar, AppErrorState, AppPagination. Module-specific:
RouteStopEditor (reorderable stops), AssignmentShiftPicker, StatTile - defined
in 07_Component_Library.md.
Analytics events (proposed)
transport.overview.open, transport.vehicles.{list,open,create,update,delete},
transport.drivers.{list,open,create,update,delete},
transport.routes.{list,open,create,update,delete},
transport.assign.{submit,duplicate,success},
transport.assignments.{list,remove} (all proposed).
Keyboard, landscape, tablet, desktop
- Forms: keyboard-avoidance + scroll; tablet/desktop constrained width (00-shared/05 §2).
- Route stop editor: full-width on phone; two-column stop list + preview on tablet.
- Lists: master-detail on tablet; desktop hover row highlights.
06 - Screen Specifications (Transport Module)
- 0. Shared states (every screen)
- 1. Transport Overview (
/transport) - 2. Vehicle List (
/transport/vehicles) - 3. Vehicle Detail (
/transport/vehicles/:id) - 4. Vehicle Create / Update Form (
/transport/vehicles/newor sheet) - 5. Driver List (
/transport/drivers) - 6. Driver Detail (
/transport/drivers/:id) - 7. Driver Create / Update Form
- 8. Route List (
/transport/routes) - 9. Route Detail (
/transport/routes/:id) - 10. Route Editor w/ stops (create & update)
- 11. Assign Student to Route (bottom sheet / dialog)
- 12. Student Route Assignments (
/transport/students/:studentId/transport) - 13. Live Tracking (
/transport/live)(planned) - 14. Bus Attendance (
/transport/attendance)(planned) - 15. Transport Fees (
/transport/fees)(planned) - Cross-screen rules
Detailed specifications per screen: layout, wire contract, states, interactions, errors, permissions, a11y and analytics. Read alongside 05_Screen_Inventory.md and 00-shared/03 (components), 00-shared/06 (state), 00-shared/09 (a11y).
0. Shared states (every screen)
| State | Rendering | Source of truth |
|---|---|---|
| idle/loading | AppSkeleton per block; list rows as skeleton tiles | 00-shared/06 §3.1 |
| error offline | AppOfflineBanner + native retry; data shown stale if cached | 00-shared/10 §2 |
| error API | AppErrorState(code, message, onRetry); 401 -> re-auth; 403 -> permission copy | 00-shared/03 |
| empty | AppEmptyState with CTA | 00-shared/03 |
| 409 conflict | inline field/dialog message with corrective action (see each screen) | transport.service.ts:38-45, 113-116, 177-190, 252-258 |
| 404 | detail screens -> AppErrorState + back | transport.service.ts:80, 151, 220 |
Error envelope: { statusCode, message, timestamp, path } per 00-shared/07 §3
(HttpExceptionFilter).
1. Transport Overview (/transport)
Layout
- AppBar: title "Transport", global search icon disabled (no search endpoint).
- Body:
StatTilegrid (Vehicles / Routes / Drivers) - count frommeta.totalItemsof each list call withlimit=1(transport.service.ts:75, 146, 215); each tile navigates to its list. - Quick actions card: "Assign student to route", "Student assignments" (student search first), "Route editor" shortcut to route list.
- FAB: "Assign student" (requires
transport.assign,permissions.constants.ts:74).
Wire contract
- 3 parallel GETs:
GET /transport/vehicles?page=1&limit=1,GET /transport/routes?page=1&limit=1,GET /transport/drivers?page=1&limit=1(transport.controller.ts:36-40, 66-70, 96-100). - Envelope per 00-shared/07 §2:
{ data: [...], meta: { page, limit, totalItems, totalPages, hasNext, hasPrevious } }.
States
- Per-tile loading/error - one failed tile shows
AppErrorStatewith retry for that tile only; the rest render. - All three fail -> full-page error.
Interactions
- Tile tap -> list screen. FAB -> assign sheet (screen §7).
- Pull-to-refresh re-fires all three GETs.
a11y / motion
- StatTile semantics label "Vehicles: 12". Fade-in stagger
m-base(00-shared/08).
Analytics (proposed)
transport.overview.open, transport.overview.tile.{vehicles,routes,drivers}.
2. Vehicle List (/transport/vehicles)
Layout
- AppBar: "Vehicles" + FAB "New vehicle" (
.create). - List of
AppListTile: leading vehicle type icon (bus/van/car), titleplateNumber(monospace), subtitlemodel + capacity seats, trailingAppBadge(status: active=success, maintenance=warning, inactive=neutral) +AppMenu(Edit / Delete). AppPaginationfooter (page, limit from state;metafrom envelope).
Wire contract
GET /transport/vehicles?page=1&limit=20(transport.controller.ts:36-40); server sort:plateNumberasc (transport.service.ts:71).- Item shape:
{ _id, plateNumber, model, capacity, type, status, year?, color?, insuranceExpiry?, notes?, tenantId, isDeleted, version, createdAt, updatedAt }(vehicle.schema.ts:19-50).
States
- loading: 6 skeleton tiles; empty: "No vehicles yet" + CTA; error per §0.
Interactions
- Tap row -> detail (§3). Delete via menu ->
AppDialogconfirm -> optimistic remove; on 409 ("Vehicle is assigned to a route.",transport.service.ts:98) show dialog with "View blocking routes" button -> route list. - Infinite approach: pagination controls (page jumps) - not infinite scroll (00-shared/01 §"pagination, not infinite scroll alone").
a11y / motion
- Row delete confirmation focus trap; status badge semantics label.
3. Vehicle Detail (/transport/vehicles/:id)
Layout
- AppBar: plateNumber + back; actions: Edit (
.update), Delete (.delete). AppCard"Fleet info": model, type, capacity, year, color, status badge, insuranceExpiry (localized date), notes.- Audit footer: createdAt / updatedAt / version (read-only,
base.repository.ts:57-66).
Wire contract
GET /transport/vehicles/:id(transport.controller.ts:42-46); 404 "Vehicle not found." ->AppErrorState+ back (transport.service.ts:80).
Interactions
- Delete -> confirm dialog ->
DELETE /transport/vehicles/:id(transport.controller.ts:54-58); 409 route reference -> explain dialog (transport.service.ts:94-99); success -> pop to list + snackbar; emitsVehicleDeleted(transport.service.ts:102-109). - Edit -> create/update form (§4) prefilled; PATCH returns updated doc.
4. Vehicle Create / Update Form (/transport/vehicles/new or sheet)
Layout
- Fields: plateNumber*, model*, capacity* (number, min 1), type* (segmented:
bus/van/car), year (number), color, insuranceExpiry (date), notes (multiline) -
create-vehicle.dto.ts:5-42. - Validation per DTO: required
IsStringfor plateNumber/model/type; capacityIsNumber @Min(1); yearIsNumberoptional (create-vehicle.dto.ts:14-26). - Submit button disabled until valid; loading spinner on submit.
Wire contract
- Create:
POST /transport/vehicles(transport.controller.ts:30-34). - Update:
PATCH /transport/vehicles/:idwithUpdateVehicleDto(PartialType,update-vehicle.dto.ts:4; any subset allowed). - 409: "Vehicle "
" already exists." inline under plateNumber ( transport.service.ts:42-44). - On success: emit
VehicleCreated(transport.service.ts:47-57) -> navigate to detail + snackbar.
Notes
- plateNumber trimmed server-side (
vehicle.schema.ts:21-22); normalize case client-side before submit to reduce false conflicts.
5. Driver List (/transport/drivers)
Layout
- AppBar: "Drivers" + FAB "New driver".
AppListTile:AppAvatarinitials, title "firstName lastName", subtitlelicenseNumber + phone, trailing status badge (active/on_leave/inactive) +AppMenu(Edit / Delete).AppPaginationfooter.
Wire contract
GET /transport/drivers?page=1&limit=20(transport.controller.ts:96-100); sortfirstNameasc (transport.service.ts:211).- Item:
{ _id, firstName, lastName, licenseNumber, phone, email?, status, licenseExpiry?, address?, emergencyContact?, joinedAt?, notes?, ...base }(driver.schema.ts:15-46).
States / interactions
- Same as §2. Delete 409: "Driver is assigned to a route."
(
transport.service.ts:245).
6. Driver Detail (/transport/drivers/:id)
Layout
- AppBar: full name; actions Edit / Delete.
AppCard"License & contact": licenseNumber, phone, email, licenseExpiry (warn style when < 30 days - client heuristic; no server check), address, emergencyContact, joinedAt.AppCard"Status": status badge + notes.
Wire contract
GET /transport/drivers/:id(transport.controller.ts:102-106); 404 "Driver not found." (transport.service.ts:220).
Interactions
- Edit -> form (§7); Delete -> confirm ->
DELETE /transport/drivers/:id(transport.controller.ts:114-118) with 409 guard (transport.service.ts:241-246).
7. Driver Create / Update Form
Layout
- Fields: firstName*, lastName*, licenseNumber*, phone*, email (email keyboard,
IsEmail-create-driver.dto.ts:23-24), licenseExpiry (date,IsDateString-:28-29), address, emergencyContact, joinedAt (date,:43-44), notes. - Update:
UpdateDriverDto= PartialType (update-driver.dto.ts:4).
Wire contract
- Create:
POST /transport/drivers(transport.controller.ts:90-94). - Update:
PATCH /transport/drivers/:id(transport.controller.ts:108-112). - Dates converted server-side (
transport.service.ts:191-197, 228-234). - 409 inline: "Driver with license "
" already exists." / "Driver with phone " " already exists." ( transport.service.ts:181-183, 187-189).
8. Route List (/transport/routes)
Layout
- AppBar: "Routes" + FAB "New route".
AppListTile: title name, subtitle "startPoint -> endPoint", trailing "N stops" chip + status badge (active/inactive) +AppMenu(Edit / Delete).AppPaginationfooter.
Wire contract
GET /transport/routes?page=1&limit=20(transport.controller.ts:66-70); sortnameasc (transport.service.ts:142).- Item:
{ _id, name, startPoint, endPoint, stops: [{name, order}], vehicleId?, driverId?, status, estimatedDuration?, notes?, ...base }(route.schema.ts:14-39).
Interactions
- Delete 409: "Route has active student assignments."
(
transport.service.ts:170) -> dialog offering "View students" (assignments search by route is not exposed; offer student-side lookup instead) or dismiss.
9. Route Detail (/transport/routes/:id)
Layout
- AppBar: route name; actions: Edit, Delete, "Assign students".
AppCard"Route": startPoint -> endPoint, status badge, estimatedDuration, notes.AppCard"Stops": numbered list 1..N fromstopsordered byorder(route.schema.ts:23-24); start/end pinned as stop 0 / last for clarity (client presentation).AppCard"Resources": assigned vehicle (plateNumber via ref lookup) and driver (name); ids arrive raw (route.schema.ts:26-30) - resolve client-side by fetching detail or batch list; unresolved -> "Not assigned".
Wire contract
GET /transport/routes/:id(transport.controller.ts:72-76); 404 "Route not found." (transport.service.ts:151).- Note: no populated payload -
vehicleId/driverIdare ObjectIds; resolution is a client concern (2 extra GETs or list cache).
Interactions
- Edit -> editor (§10). Delete -> confirm ->
DELETE /transport/routes/:id(transport.controller.ts:84-88).
10. Route Editor w/ stops (create & update)
Layout
- Section A "Basics": name*, startPoint*, endPoint*, estimatedDuration (minutes, number), notes.
- Section B "Stops":
RouteStopEditor- rows "Stop name" + up/down arrows and drag handles; add-stop field; stops carryorder1..N client-maintained (route.schema.ts:23-24); max ~20 stops client guard (no server limit). - Section C "Resources": vehicle picker (searchable dropdown from vehicle list cache), driver picker (same); "Clear" sets field absent on submit.
- Save button: Create ->
POST /transport/routes(transport.controller.ts:60-64); Edit ->PATCH /transport/routes/:id(transport.controller.ts:78-82).
Wire contract / validation
- DTO:
stopsarray of{name: string, order: number}viaValidateNested(create-route.dto.ts:11-19, 34-39);vehicleId/driverIdplain strings, converted to ObjectId server-side (transport.service.ts:117-119, 159-161). - 409 duplicate name on create: "Route "
" already exists." ( transport.service.ts:115). On update: name conflict surfaces as 409 from unique index{tenantId, name}(route.schema.ts:44).
States
- Draft persisted locally (prefs) while editor open; reorder animation
m-base(00-shared/08).
a11y
- Reorder actions exposed as explicit up/down buttons (drag optional); every stop row semantics label "Stop 2: Market Road".
11. Assign Student to Route (bottom sheet / dialog)
Layout
- Header "Assign student to route".
- Step 1: route picker (dropdown; label shows name + stop count).
- Step 2: student picker (search-as-you-type; shows admissionNumber; filter by
transportRequiredtoggle default on -student.schema.ts:53-54). - Step 3: shift segmented control morning / evening / both*
(
assign-route.dto.ts:13-15;IsEnum(['morning','evening','both'])). - Optional: stopName (text, or pick from route stops when route selected), notes.
- Submit "Assign".
Wire contract
POST /transport/assignbody{ routeId, studentId, shift, stopName?, notes? }(assign-route.dto.ts:4-26;transport.controller.ts:120-124).- Server sets
assignedAt: new Date()and default statusactive(transport.service.ts:259-266,route-assignment.schema.ts:23-28).
States & errors
- 409 "Student already assigned to this route." (
transport.service.ts:257) -> inline warning + "View existing" opens the student's assignment screen. - Success ->
StudentRouteAssignedevent (transport.service.ts:267-278), snackbar, sheet closes.
Note (client-side only)
- Capacity overflow guard is NOT server-enforced (
transport.service.ts:251-266does no capacity check) - warn client-side when route's assigned-count (derived from student-side lookups) reaches vehicle capacity (vehicle.schema.ts:27-28); documented as QA item (14 §3).
12. Student Route Assignments (/transport/students/:studentId/transport)
Layout
- AppBar: student name; action "Assign" (opens §11 prefilled with student).
- List:
AppListTileper assignment - title route name (populated), subtitle "shift + stopName", status badge, menu Remove. AppEmptyState: "No route assigned" + CTA.
Wire contract
GET /transport/assignments/:studentId-> array of assignments withrouteIdpopulated (transport.controller.ts:126-130,route-assignment.repository.ts:20-24).- Remove:
DELETE /transport/assignments/:id(transport.controller.ts:132-136); 404 "Assignment not found." -> row already gone, refetch (transport.service.ts:289-290).
Interactions
- Remove -> confirm dialog (shows route + shift) -> optimistic removal with rollback on error.
13. Live Tracking (/transport/live) (planned)
- Map with vehicle markers per active route; data source to be defined (IMPLEMENTATION_PLAN.md:229). No API today - screen renders offline banner + "coming soon" when endpoint absent.
14. Bus Attendance (/transport/attendance) (planned)
- Route/shift picker -> boarding list; QR boarding
(forward-looking).
15. Transport Fees (/transport/fees) (planned)
- Route fee definition + per-student calc (IMPLEMENTATION_PLAN.md:229); fee
line item exists in fee structures (see
design-docs/fees).
Cross-screen rules
- All destructive actions:
AppDialogconfirm, mention consequence (409 guards intransport.service.ts:94-99, 168-171, 241-246). - All create actions emit domain events (
transport.service.ts:47-57, 121-128, 267-278) - client treats event as completion signal, response is the doc. - Pagination: page/limit state per list cubit;
metafrom envelope (00-shared/06 §3.2;buildPaginationMeta-transport.service.ts:75).
07 - Component Library (Transport Module)
- 1. StatTile
- 2. EntityStatusBadge
- 3. ShiftPicker
- 4. RouteStopEditor
- 5. ResourcePicker (vehicle / driver)
- 6. AssignmentTile
- 7. DependencyGuardDialog
- 8. ConflictFieldError
- 8b. NotFoundState
- Composition map (screen -> components)
Module-specific components. Shared primitives (
AppTextField,AppButton,AppSnackbar,AppCard,AppListTile,AppBottomSheet,AppDialog,AppMenu,AppSkeleton,AppEmptyState,AppOfflineBanner,AppFAB,AppChips,AppBadge,AppAvatar,AppErrorState,AppPagination) come from 00-shared/03 and are NOT reproduced here.
1. StatTile
Overview hub tile: count + label + navigate. Props: label, count, icon,
onTap, loading, error, onRetry. Count source: meta.totalItems from
list calls (transport.service.ts:75, 146, 215). Loading -> AppSkeleton block;
error -> inline retry (per-tile independence, 06 §1).
2. EntityStatusBadge
Badge mapping per entity (source: schema enums):
| Entity | Status | Badge tone |
|---|---|---|
Vehicle (vehicle.schema.ts:13-17) | active | success |
| maintenance | warning | |
| inactive | neutral | |
Driver (driver.schema.ts:7-11) | active | success |
| on_leave | warning | |
| inactive | neutral | |
Route (route.schema.ts:7-10) | active | success |
| inactive | neutral | |
Assignment (route-assignment.schema.ts:7-10) | active | success |
| inactive | neutral |
3. ShiftPicker
Segmented control for morning | evening | both - hard IsEnum values from
assign-route.dto.ts:13-15. Displays selected shift chip on assignment tiles
("AM", "PM", "AM+PM").
4. RouteStopEditor
Ordered stop list editor used in route create/update (06 §10):
- Rows: drag handle + text field (stop name) + up/down buttons + delete.
- Ordering: client maintains
order: 1..N; submitted as array of{name, order}(create-route.dto.ts:11-19,route.schema.ts:23-24). - API:
onChanged(List<RouteStopDraft>); dirty state triggers save-enable. - a11y: explicit up/down buttons; drag is enhancement.
- Cap: 20 stops client-side (no server limit).
5. ResourcePicker (vehicle / driver)
Searchable dropdown over cached list data (vehicle/driver list from
GET /transport/vehicles|drivers, transport.controller.ts:36-40, 96-100):
- Displays
plateNumber/firstName lastName(+ subtitlemodel/licenseNumber). - Emits selected
idor null (clear); on submit, null -> field omitted (create-route.dto.ts:41-49). - Empty cache -> fetches page 1; refresh action.
6. AssignmentTile
AppListTile wrapper for an assignment (used in 06 §12): title = populated
route name, subtitle = ShiftPicker chip + stopName, trailing status badge +
AppMenu (Remove). Data: RouteAssignment with routeId populated
(route-assignment.repository.ts:20-24).
7. DependencyGuardDialog
Delete-confirmation variant for 409 conflicts (transport.service.ts:94-99, 168-171, 241-246): shows the server message, a "View related" action when
mappable (vehicle/driver -> route list; route -> student assignments note),
and Cancel. Never retries blindly (conflict is deterministic).
8. ConflictFieldError
Inline field error for duplicate checks, per source:
| Field | Source guard | Server message |
|---|---|---|
| vehicle.plateNumber | transport.service.ts:38-45 | Vehicle "<plate>" already exists. |
| route.name | transport.service.ts:113-116 | Route "<name>" already exists. |
| driver.licenseNumber | transport.service.ts:177-184 | Driver with license "<n>" already exists. |
| driver.phone | transport.service.ts:185-190 | Driver with phone "<p>" already exists. |
| assign (route+student) | transport.service.ts:252-258 | Student already assigned to this route. |
Props: field, message, action? (e.g. "View existing" for assignment).
8b. NotFoundState
Detail-screen 404 rendering (transport.service.ts:80, 151, 220): icon, "Not
found", back button; distinct from generic AppErrorState copy.
Composition map (screen -> components)
| Screen (05) | Components |
|---|---|
| 1 Overview | StatTile x3, AppCard, AppListTile, AppFAB |
| 2-3 Vehicle list/detail | EntityStatusBadge, DependencyGuardDialog, AppPagination |
| 4 Vehicle form | ConflictFieldError, AppTextField/DateField |
| 5-6 Driver list/detail | EntityStatusBadge, AppAvatar, DependencyGuardDialog |
| 7 Driver form | ConflictFieldError, DateField |
| 8-9 Route list/detail | EntityStatusBadge, ResourcePicker (read) |
| 10 Route editor | RouteStopEditor, ResourcePicker, ConflictFieldError |
| 11 Assign sheet | ShiftPicker, ResourcePicker (route), StudentPicker (students module), ConflictFieldError |
| 12 Student assignments | AssignmentTile, ShiftPicker, AppEmptyState |
| 13-15 planned | map/attendance/fee components (planned) |
08 - Form Specifications (Transport Module)
- 1. Vehicle Create Form (
create-vehicle.dto.ts:5-42) - 2. Driver Create Form (
create-driver.dto.ts:4-50) - 3. Route Create Form (
create-route.dto.ts:21-60) - 4. Assign Student to Route (
assign-route.dto.ts:4-26) - 5. Form behaviour (shared)
All create/update/assign forms. Validation mirrors DTOs exactly (
create-vehicle.dto.ts,create-driver.dto.ts,create-route.dto.ts,assign-route.dto.ts; updates are PartialTypes of the creates -update-*.dto.ts). Server is the source of truth; client validates for latency, server for correctness.
Legend: * required. All forms: keyboard-avoidance, autofill where noted,
submit button disabled until valid, loading state on submit.
1. Vehicle Create Form (create-vehicle.dto.ts:5-42)
| Field | Type | Required | Validation (DTO) | Notes |
|---|---|---|---|---|
| plateNumber | text | * | IsString (:7-8) | trim; unique/tenant (vehicle.schema.ts:50); 409 inline (transport.service.ts:38-45) |
| model | text | * | IsString (:11-12) | |
| capacity | number | * | IsNumber @Min(1) (:15-17) | step 1; warn if > 80 (client heuristic) |
| type | segmented | * | IsEnum(VehicleType) (:19-21) | bus / van / car (vehicle.schema.ts:7-11) |
| year | number | - | IsNumber @IsOptional (:24-26) | |
| color | text | - | IsString @IsOptional (:28-31) | |
| insuranceExpiry | date | - | string @IsOptional (:33-36) | note: schema prop declared String, typed Date (vehicle.schema.ts:42-43) - send ISO string |
| notes | multiline | - | IsString @IsOptional (:38-41) |
Submit: POST /transport/vehicles (transport.controller.ts:30-34).
Update: same fields all optional (update-vehicle.dto.ts:4), PATCH /transport/vehicles/:id (transport.controller.ts:48-52).
2. Driver Create Form (create-driver.dto.ts:4-50)
| Field | Type | Required | Validation (DTO) | Notes |
|---|---|---|---|---|
| firstName | text | * | IsString (:6-8) | autofill given-name |
| lastName | text | * | IsString (:10-12) | autofill family-name |
| licenseNumber | text | * | IsString (:14-16) | unique/tenant (driver.schema.ts:50); 409 inline (transport.service.ts:177-184) |
| phone | tel | * | IsString (:18-20) | unique/tenant (driver.schema.ts:51); 409 inline (:185-190) |
| - | IsEmail @IsOptional (:22-24) | autofill email | ||
| licenseExpiry | date | - | IsDateString @IsOptional (:27-29) | parsed to Date server-side (transport.service.ts:193-196) |
| address | text | - | IsString @IsOptional (:31-34) | autofill street-address |
| emergencyContact | text | - | IsString @IsOptional (:36-39) | tel keyboard |
| joinedAt | date | - | IsDateString @IsOptional (:41-44) | parsed to Date (transport.service.ts:192-193) |
| notes | multiline | - | IsString @IsOptional (:46-49) |
Submit: POST /transport/drivers (transport.controller.ts:90-94).
Update: PATCH /transport/drivers/:id (transport.controller.ts:108-112),
update-driver.dto.ts:4.
3. Route Create Form (create-route.dto.ts:21-60)
| Field | Type | Required | Validation (DTO) | Notes |
|---|---|---|---|---|
| name | text | * | IsString (:23-25) | unique/tenant (route.schema.ts:44); 409 inline (transport.service.ts:113-116) |
| startPoint | text | * | IsString (:27-29) | |
| endPoint | text | * | IsString (:31-33) | |
| stops | RouteStopEditor | - | IsArray @ValidateNested({each}) @Type(RouteStopDto) (:34-39) | items {name: IsString, order: IsNumber} (:11-19) |
| vehicleId | ResourcePicker | - | IsString @IsOptional (:41-44) | converted to ObjectId (transport.service.ts:117-119) |
| driverId | ResourcePicker | - | IsString @IsOptional (:46-49) | converted to ObjectId (transport.service.ts:119) |
| estimatedDuration | number | - | IsNumber @IsOptional (:51-54) | minutes |
| notes | multiline | - | IsString @IsOptional (:56-59) |
Submit: POST /transport/routes (transport.controller.ts:60-64).
Update: PATCH /transport/routes/:id (transport.controller.ts:78-82),
update-route.dto.ts:4; ObjectId conversion also on update
(transport.service.ts:159-161).
4. Assign Student to Route (assign-route.dto.ts:4-26)
| Field | Type | Required | Validation (DTO) | Notes |
|---|---|---|---|---|
| routeId | ResourcePicker | * | IsString (:6-8) | |
| studentId | StudentPicker | * | IsString (:10-12) | from students module; filter transportRequired toggle (student.schema.ts:53-54) |
| shift | ShiftPicker | * | IsEnum(['morning','evening','both'] as const) (:13-15) | |
| stopName | text | - | IsString @IsOptional (:17-20) | suggest from route stops |
| notes | multiline | - | IsString @IsOptional (:22-25) |
Submit: POST /transport/assign (transport.controller.ts:120-124).
Server sets assignedAt = now, status default active
(transport.service.ts:259-266, route-assignment.schema.ts:23-28).
409 duplicate (route, student) inline (transport.service.ts:252-258).
5. Form behaviour (shared)
- 409 conflict handling:
ConflictFieldErrorunder the offending field with server message verbatim (07 §8); never silently retry. - Debounce: search-as-you-type fields (student picker) 300 ms; single-flight requests (00-shared/11).
- Dirty tracking: updates send only touched fields (PartialType semantics,
update-*.dto.ts); unchanged optional dates re-sent as-is to avoid accidental clearing (transport.service.ts:159-161, 228-234re-parse only when present). - Offline: submit blocked with
AppOfflineBanner; no queue (no offline mutation contract). - Success: navigate to detail + snackbar; events
VehicleCreated,RouteCreatedfire server-side (transport.service.ts:47-57, 121-128).
09 - User Behaviour (Transport Module)
- 1. Defaults
- 2. Read behaviour
- 3. Conflict behaviour (users hit these constantly)
- 4. Deletion mental model
- 5. Status lifecycle expectations
- 6. Search expectations (gap)
- 7. Multi-tenant behaviour
- 8. Permission-aware behaviour
Behaviour patterns, defaults and expectations for transport users. Wherever server behaviour constrains UX, the source is cited and the client adapts.
1. Defaults
| Context | Default | Source |
|---|---|---|
| List page size | 20, page 1 | transport.controller.ts:38, 68, 98 |
| List sort | vehicles plateNumber asc; routes name asc; drivers firstName asc | transport.service.ts:71, 142, 211 |
| Vehicle status on create | active | vehicle.schema.ts:33-34 |
| Driver status on create | active | driver.schema.ts:30-31 |
| Route status on create | active | route.schema.ts:32-33 |
| Assignment status | active; assignedAt = now | route-assignment.schema.ts:23-28, transport.service.ts:264 |
| Assignment stops | [] | route.schema.ts:23-24 |
| New-entity nav | -> detail screen | conventional |
2. Read behaviour
- List -> detail: taps on rows; users expect full info on detail. ObjectId
refs (
route.schema.ts:26-30) arrive unresolved - users expect vehicle plate / driver name, so the client resolves via cached list data or fetch; show "Not assigned" when absent (never raw ObjectId). - Pagination: users page rather than scroll infinitely (00-shared/01);
footer shows
metaposition (transport.service.ts:75). - Refresh: pull-to-refresh re-fetches; sorted server-side - client never re-sorts.
3. Conflict behaviour (users hit these constantly)
- Duplicate plate on vehicle create - user corrects plate; expects inline
error, not a dialog (
transport.service.ts:38-45). - Duplicate license/phone on driver create - two independent uniqueness
rules (
driver.schema.ts:50-51); error must say WHICH field conflicted (transport.service.ts:177-190). - Duplicate route name - rename or accept 409 (
transport.service.ts:113-116). - Student already on route - user intent is usually "change shift or stop"
(schema allows a second assignment for a different route - uniqueness is per
route+student,
route-assignment.schema.ts:42-45); the assign sheet must offer "View existing / edit instead" (06 §11). - Delete blocked by reference - users understand dependencies; dialog
explains "vehicle is assigned to a route" and navigates to the blocker
(
transport.service.ts:94-99, 168-171, 241-246).
4. Deletion mental model
- Deletes are soft (
base.repository.ts:68-74) - nothing is permanently gone; list queries exclude deleted (isDeleted: false,base.repository.ts:20-30). UI copy: "Remove" not "Delete forever". - A removed vehicle/driver/route reappears only via re-create (no restore endpoint exists - gap).
5. Status lifecycle expectations
- Vehicle:
active -> maintenance -> activeis the common loop (vehicle.schema.ts:13-17); maintenance vehicles should be prevented from new route assignment client-side (no server check - QA item 14 §3). - Driver:
active -> on_leave -> active(driver.schema.ts:7-11); expired license warning is client-side heuristic (no server validation oflicenseExpiry). - Assignment: no status-flip endpoint exists (
route-assignment.schema.ts:23-28has the field;DELETE /assignments/:idis the only mutation) - UI offers Remove only; "mark inactive" is(planned).
6. Search expectations (gap)
- Users will type to filter lists (plate, name, phone). Backend lists accept
only
page/limit(transport.controller.ts:36-40, 66-70, 96-100). Client: local filter of loaded pages + clear hint; server-side search is(planned)(flagged in 01 §5).
7. Multi-tenant behaviour
- Every document is tenant-scoped and never user-controlled
(
base.repository.ts:20-36;AGENTS.mdconventions) - no tenant picker, no cross-tenant results, ever.
8. Permission-aware behaviour
- Surfaces gate on
transport.vehicle.*,transport.route.*,transport.driver.*,transport.assign(permissions.constants.ts:62-74). - Read-only roles see lists/detail, no FAB/menu actions; 403 -> permission copy, never "error" (06 §0).
10 - Interaction Specification (Transport Module)
- 1. Navigation
- 2. List interactions
- 3. Delete flow (all three entities + assignment)
- 4. Form interactions
- 5. RouteStopEditor interactions
- 6. Resource pickers (vehicle/driver/route)
- 7. Assignment interactions
- 8. Offline behaviour
- 9. Motion summary
- 10. Keyboard & platform
Precise interaction rules per screen flow. Motion tokens per 00-shared/08 (
m-fast= 150 ms,m-base= 250 ms,m-slow= 400 ms).
1. Navigation
| From | To | Trigger | Transition |
|---|---|---|---|
| Overview tile | list screens | tap | m-base slide |
| List row | detail | tap | m-base slide (hero: row to detail header) |
| Detail | edit form | FAB/action | m-base slide |
| List | create form | FAB | sheet: m-base bottom sheet slide |
| Route detail | assign sheet | "Assign students" | sheet slide |
| Student assignments | assign sheet | "Assign" | sheet slide, studentId prefilled |
Back always pops; unsaved form guard dialog (discard / keep editing).
2. List interactions
- Row tap -> detail; long-press on desktop opens
AppMenudirectly. - Pull-to-refresh: re-fires current page; skeleton preserved, content swap when response arrives; error keeps stale data + banner.
- Pagination footer: Previous/Next + page number + "of N" from
meta(buildPaginationMeta-transport.service.ts:75); disabled states at bounds (hasNext/hasPrevious). - Menu actions: Edit (
.update), Delete (.delete) - shown per permission (permissions.constants.ts:62-74).
3. Delete flow (all three entities + assignment)
- Menu -> Delete.
AppDialogconfirm: "Remove <plateNumber / name>? This is reversible for admins." (soft delete,base.repository.ts:68-74).- Optimistic: row removed +
AppSnackbar"Removed". - Rollback on error:
- 404 -> refetch list (
transport.service.ts:101, 173, 248, 290). - 409 -> restore row +
DependencyGuardDialog(blocking dependency message,transport.service.ts:94-99, 168-171, 241-246) with "View" action.
- 404 -> refetch list (
- Events fire server-side (
VehicleDeleted,transport.service.ts:102-109).
4. Form interactions
- Validation timing: on-field-blur for required/format; live for conflicts after a submitted 409 (08 §5).
- Submit: button loading spinner; disable double-submit; success -> pop with
snackbar; 409 ->
ConflictFieldError+ focus first invalid (07 §8;transport.service.ts:38-45, 113-116, 177-190, 252-258). - Optional dates (
insuranceExpiry,licenseExpiry,joinedAt): date picker; empty sends absent field (PartialType semantics,update-*.dto.ts:4); server re-parses only when present (transport.service.ts:159-161, 228-234).
5. RouteStopEditor interactions
- Add stop: trailing field + add button -> appended with next
order; row animates inm-fast. - Reorder: drag (mobile) or up/down buttons (a11y) ->
m-basereorder animation;ordervalues re-sequenced 1..N on every change (route.schema.ts:23-24). - Delete stop: row swipe (mobile) or menu; confirm not needed (undo snackbar 4 s).
- Persistence: route update PATCHes whole
stopsarray (create-route.dto.ts:34-39).
6. Resource pickers (vehicle/driver/route)
- Open: cached list first (
GET /transport/vehicles|drivers|routespage 1,transport.controller.ts:36-40, 96-100, 66-70), fetch-on-focus. - Filter: local case-insensitive match on
plateNumber/name/firstName; single-flight; empty result -> "No match - check list scope". - Select: chip + clear affordance; clearing omits the field on submit
(
create-route.dto.ts:41-49).
7. Assignment interactions
- Shift change after selection: allowed (new assignment only; editing an
existing assignment = remove + re-assign - no PATCH endpoint for
assignments,
transport.controller.ts:120-136). - stopName auto-suggest: from selected route's stops (
route.schema.ts:23-24). - 409 duplicate: warning + "View existing assignment" -> student assignment
screen (
transport.service.ts:252-258). - Capacity pre-check (client heuristic, 06 §11): warn "route appears full" before submit; server does not enforce - QA 14 §3.
8. Offline behaviour
- Lists: cached render +
AppOfflineBanner; mutations blocked with banner (00-shared/10 §2, §3.3). - Detail screens: cache-keyed by id; stale allowed with "updated x ago".
9. Motion summary
| Interaction | Token |
|---|---|
| Screen transitions | m-base slide |
| Bottom sheets / dialogs | m-base (sheet), m-fast (dialog) |
| Row reorder / add-stop | m-base / m-fast |
| Delete rollback (409) | shake m-fast + dialog |
| Error focus jump | m-fast |
10. Keyboard & platform
- Forms:
resizeToAvoidBottomInset+ scroll; phone portrait-first. - Desktop: hover row highlight, long-press menu = context menu.
- a11y per 00-shared/09: live-region on snackbars, semantics on badges, explicit focus order in dialogs.
11 - Design System Mapping (Transport Module)
- 1. Tokens used (00-shared/02)
- 2. Component -> token mapping
- 3. Status colour mapping (semantic)
- 4. Density & layout
- 5. Type & voice
- 6. Module exceptions (flag to shared team)
Maps transport screens to the shared design system (00-shared/02 tokens, 00-shared/04 mapping, 00-shared/03 components). No new tokens introduced; module-only patterns are listed as exceptions.
1. Tokens used (00-shared/02)
| Token | Where |
|---|---|
color-success / color-warning / color-neutral | EntityStatusBadge tones (07 §2) |
color-danger | delete actions, conflict field errors |
color-primary | FABs, primary CTAs, active chips |
typography-title-medium | list tile titles, stat counts |
typography-body-medium | subtitles (model, license, phone) |
typography-label-large | section headers ("Stops", "Resources") |
spacing-* (4 px grid) | card/list padding, stop editor rows |
radius-medium | cards, tiles, badges |
elevation-low | overview tiles, detail cards |
motion-m-fast/base | transitions per 10 §9 |
2. Component -> token mapping
| Component (07) | Container | Fill | Border | Text |
|---|---|---|---|---|
| StatTile | AppCard | surface | color-outline-variant | count = typography-headline |
| EntityStatusBadge | AppBadge | status tone 10 % | tone | tone text |
| ShiftPicker | segmented chips | primary 10 % | primary | primary text |
| RouteStopEditor | AppCard list | surface | outline-variant | body-medium |
| ResourcePicker | AppTextField + chips | surface | outline | body-medium |
| AssignmentTile | AppListTile | surface | none | title = title-medium |
| DependencyGuardDialog | AppDialog | surface-container | outline | danger title |
3. Status colour mapping (semantic)
Server enums -> colour, single source for the module
(vehicle.schema.ts:13-17, driver.schema.ts:7-11, route.schema.ts:7-10,
route-assignment.schema.ts:7-10):
| Status | Colour | Icon |
|---|---|---|
| active | success | check-circle |
| maintenance | warning | wrench |
| on_leave | warning | pause-circle |
| inactive | neutral | minus-circle |
4. Density & layout
- Lists: standard density (
AppListTiledefault 56 dp); detail cards usespacing-4. - Master-detail (tablet/desktop, 00-shared/05 §2): left list 320 dp min, right detail pane.
- Overview: StatTile grid - 2 columns phone, 3-4 tablet/desktop.
5. Type & voice
- Titles: entity names verbatim (plateNumber, route name, driver full name).
- Empty states: action-oriented ("No vehicles yet - add your first vehicle").
- Conflict messages: server message verbatim (07 §8), never paraphrased -
they are exact 409 bodies (
transport.service.ts:42-44, 115, 181-183, 187-189, 257).
6. Module exceptions (flag to shared team)
plateNumberalways monospace (typographyfont featuretnum).- Capacity displayed with unit "seats".
- Stops numbering uses ordinal prefix ("Stop 1", "Stop 2") rather than bullets.
- Planned screens (live map, attendance, fees) will need
mappalette + location markers - deferred to(planned)implementation (IMPLEMENTATION_PLAN.md:229).
12 - API Mapping (Transport Module)
- 1. Vehicles (
transport.controller.ts:30-58) - 2. Routes (
transport.controller.ts:60-88) - 3. Drivers (
transport.controller.ts:90-118) - 4. Assignments (
transport.controller.ts:120-136) - 5. Request examples
- 6. Response shapes
- 7. Error map
- 8. Planned / not yet in source
Exact wire contracts for the Transport module. Base path
/api/v1(URI versioning,main.ts); all endpoints JWT-guarded (transport.controller.ts:23-26), tenant-scoped (base.repository.ts:20-30). Envelopes per 00-shared/07 §2-3. Permissions frompermissions.constants.ts:62-74(guards on endpoints not yet wired -AGENTS.md).
1. Vehicles (transport.controller.ts:30-58)
| Method | Path | Permission | DTO / source | Description |
|---|---|---|---|---|
| POST | /transport/vehicles | transport.vehicle.create | CreateVehicleDto (create-vehicle.dto.ts:5-42) | Add a vehicle; 409 dup plate (transport.service.ts:38-45); emits VehicleCreated (:47-57) |
| GET | /transport/vehicles?page=1&limit=20 | transport.vehicle.read | - | List, sort plateNumber asc, paginated meta (transport.service.ts:61-76; transport.controller.ts:36-40) |
| GET | /transport/vehicles/:id | transport.vehicle.read | - | Get by ID; 404 (transport.service.ts:78-82) |
| PATCH | /transport/vehicles/:id | transport.vehicle.update | UpdateVehicleDto (PartialType, update-vehicle.dto.ts:4) | Update; 404 (transport.service.ts:84-91) |
| DELETE | /transport/vehicles/:id | transport.vehicle.delete | - | Soft delete; 409 if on a route (transport.service.ts:93-110); emits VehicleDeleted |
2. Routes (transport.controller.ts:60-88)
| Method | Path | Permission | DTO / source | Description |
|---|---|---|---|---|
| POST | /transport/routes | transport.route.create | CreateRouteDto (create-route.dto.ts:21-60) | Create; 409 dup name (transport.service.ts:112-130); emits RouteCreated |
| GET | /transport/routes?page=1&limit=20 | transport.route.read | - | List, sort name asc (transport.service.ts:132-147) |
| GET | /transport/routes/:id | transport.route.read | - | 404 (transport.service.ts:149-153) |
| PATCH | /transport/routes/:id | transport.route.update | UpdateRouteDto (PartialType) | Update incl. stops/vehicleId/driverId; ObjectId conversion (transport.service.ts:155-165) |
| DELETE | /transport/routes/:id | transport.route.delete | - | Soft delete; 409 if active assignments (transport.service.ts:167-174) |
3. Drivers (transport.controller.ts:90-118)
| Method | Path | Permission | DTO / source | Description |
|---|---|---|---|---|
| POST | /transport/drivers | transport.driver.create | CreateDriverDto (create-driver.dto.ts:4-50) | Add; 409 dup license OR phone (transport.service.ts:176-199) |
| GET | /transport/drivers?page=1&limit=20 | transport.driver.read | - | List, sort firstName asc (transport.service.ts:201-216) |
| GET | /transport/drivers/:id | transport.driver.read | - | 404 (transport.service.ts:218-222) |
| PATCH | /transport/drivers/:id | transport.driver.update | UpdateDriverDto (PartialType) | Update; date re-parse (transport.service.ts:224-238) |
| DELETE | /transport/drivers/:id | transport.driver.delete | - | Soft delete; 409 if on a route (transport.service.ts:240-249) |
4. Assignments (transport.controller.ts:120-136)
| Method | Path | Permission | DTO / source | Description |
|---|---|---|---|---|
| POST | /transport/assign | transport.assign | AssignRouteDto (assign-route.dto.ts:4-26) | Assign student to route; 409 dup (route, student) (transport.service.ts:251-280); emits StudentRouteAssigned |
| GET | /transport/assignments/:studentId | transport.assign (or route.read context) | - | Student's assignments, route populated (transport.service.ts:282-286, route-assignment.repository.ts:20-24) |
| DELETE | /transport/assignments/:id | transport.assign | - | Remove assignment (soft delete); 404 (transport.service.ts:288-291) |
5. Request examples
POST /api/v1/transport/vehicles
{ "plateNumber": "ABC-123", "model": "Toyota Coaster", "capacity": 30,
"type": "bus", "year": 2022, "color": "white" }
POST /api/v1/transport/routes
{ "name": "Route A", "startPoint": "Main Gate", "endPoint": "City Center",
"stops": [ { "name": "Market", "order": 1 }, { "name": "Station", "order": 2 } ],
"vehicleId": "64f...", "driverId": "64f...", "estimatedDuration": 45 }
POST /api/v1/transport/assign
{ "routeId": "64f...", "studentId": "64f...", "shift": "both",
"stopName": "Market", "notes": "Gate 2 pickup" }
6. Response shapes
- List:
{ data: VehicleDoc[], meta: { page, limit, totalItems, totalPages, hasNext, hasPrevious } }(buildPaginationMeta,transport.service.ts:75). - Single/created/updated:
{ data: <Doc> }(envelope interceptor,00-shared/07). - Student assignments:
{ data: RouteAssignment[] }withrouteIdpopulated (route-assignment.repository.ts:20-24). - Delete: 200/204 envelope, no body.
7. Error map
| Code | Meaning | Source |
|---|---|---|
| 401 | unauthenticated | JwtAuthGuard (transport.controller.ts:25) |
| 404 | entity missing | transport.service.ts:80, 101, 151, 173, 220, 237, 248, 290 |
| 409 | dup plate / route name / license / phone / assignment | transport.service.ts:42-44, 115, 181-183, 187-189, 257 |
| 409 | dependency blocks delete | transport.service.ts:98, 170, 245 |
| 400 | DTO validation (class-validator) | create-*.dto.ts |
8. Planned / not yet in source
| Path | Status | Source |
|---|---|---|
| Live tracking, bus attendance, fee calc, emergency endpoints | (planned) | IMPLEMENTATION_PLAN.md:229 |
| Assignment status PATCH (active/inactive) | (planned) - schema field exists, no endpoint (route-assignment.schema.ts:23-28) | gap |
| Search/filter query params (q, status, type) | (planned) - lists filter {} (transport.service.ts:69, 141, 210) | gap |
| Parent bus-tracking read | (forward-looking) | docs/user-flows/END_TO_END_USER_FLOWS.md:392-406 |
Notifications (e.g. transport.delay) | (planned) | docs/user-flows/END_TO_END_USER_FLOWS.md:430 |
13 - State Management (Transport Module)
- 1. Cubit map
- 2. VehicleListCubit
- 3. Detail + Form cubits (vehicle/driver/route share shape)
- 4. AssignmentCubit (assign sheet + student assignments)
- 5. TransportOverviewCubit
- 6. Cross-cutting
Per-screen Cubit/Bloc design on top of 00-shared/06 conventions (stack:
flutter_bloc+get_it; server state via dio repository;LoadState= Initial/Loading/Success/Error(ApiException)). Mermaid diagrams included.
1. Cubit map
| Cubit | Screen(s) (05) | Data |
|---|---|---|
VehicleListCubit | 2 | page, limit, List<Vehicle>, PaginationMeta |
VehicleDetailCubit | 3 | Vehicle, form state |
VehicleFormCubit | 4 | form model, field errors, submit |
DriverListCubit | 5 | page, limit, List<Driver>, meta |
DriverDetailCubit | 6 | Driver |
DriverFormCubit | 7 | form model, errors, submit |
RouteListCubit | 8 | page, limit, List<Route>, meta |
RouteDetailCubit | 9 | Route, resolved vehicle/driver refs |
RouteFormCubit | 10 | form model, stops order, resource picks, submit |
AssignmentCubit | 11/12 | assign sheet + student's assignment list |
TransportOverviewCubit | 1 | 3 counts (vehicles/routes/drivers) |
LiveTrackingCubit | 13 | (planned) |
AttendanceCubit / FeesCubit | 14/15 | (planned) |
Repositories (VehicleRepository, DriverRepository, RouteRepository,
AssignmentRepository in features/transport/data/) are the only layer
touching HTTP; they map envelopes to models and throw
ApiException(status, message) (00-shared/06 §2, §3).
2. VehicleListCubit
stateDiagram-v2
[*] --> Initial
Initial --> Loading: fetch(page)
Loading --> Success: 200 envelope
Loading --> Error: 401/500
Success --> Loading: nextPage/prevPage/pullToRefresh
Success --> Error: refetch fails (keep stale)
Error --> Loading: retry
Success --> Success: delete OK (optimistic)
Success --> Success: delete 409 (rollback + guard dialog)
- Fetch:
GET /transport/vehicles?page&limit(transport.controller.ts:36-40);metadrives pagination (buildPaginationMeta,transport.service.ts:75). - Delete flow: optimistic ->
DELETE /transport/vehicles/:id(transport.controller.ts:54-58); on 409/404 restore row + emitDeleteBlocked(ApiException)/NotFound(transport.service.ts:94-101).
3. Detail + Form cubits (vehicle/driver/route share shape)
sequenceDiagram
participant S as Screen
participant D as DetailCubit
participant F as FormCubit
participant R as Repo
participant A as API
S->>D: load(id)
D->>R: getById(id)
R->>A: GET /transport/<entity>/:id
A-->>R: doc (404 -> ApiException)
R-->>D: Vehicle/Driver/Route
D-->>S: Success(doc) / Error(404)
S->>F: init(doc?)
F->>R: create(dto) | update(id, dto)
R->>A: POST | PATCH /transport/<entity>
A-->>R: 409 ConflictException | 201/200 doc
R-->>F: Success(doc) | Conflict(fieldMessage)
F-->>S: SubmitDone / FieldError(conflict)
- Form state fields:
saved: bool,submitting: bool,conflicts: Map<String, String>(server 409 verbatim,transport.service.ts:38-45, 113-116, 177-190). - RouteFormCubit extras:
stops: List<RouteStopDraft>re-sequenced on reorder (route.schema.ts:23-24),vehicleId/driverIdoptional strings (create-route.dto.ts:41-49).
4. AssignmentCubit (assign sheet + student assignments)
stateDiagram-v2
[*] --> Idle
Idle --> Loading: loadStudentAssignments(studentId)
Loading --> Loaded(list): GET /transport/assignments/:studentId
Loading --> Error: 401/500
Loaded --> Submitting: submitAssign(dto)
Submitting --> Loaded: 201 (append, refresh)
Submitting --> Conflict: 409 duplicate
Conflict --> Submitting: change route/student, retry
Loaded --> Submitting: removeAssignment(id)
Submitting --> Loaded: 200 (optimistic remove)
Submitting --> Error: 404 (refetch)
- Submit:
POST /transport/assignbody fromAssignRouteDto(assign-route.dto.ts:4-26;transport.controller.ts:120-124). - 409 ->
Conflictwith message "Student already assigned to this route." (transport.service.ts:257) + "View existing" action -> student assignment screen (05§10). - Remove:
DELETE /transport/assignments/:id(transport.controller.ts:132-136).
5. TransportOverviewCubit
Three independent fetches (limit=1 for counts); per-tile LoadState
(06 §1) so one failure never blanks the hub. Data: meta.totalItems
(transport.service.ts:75, 146, 215). Refetch all on pull-to-refresh.
6. Cross-cutting
- Cache: entity lists cached in memory (
Hiveoptional) per tenant; detail screens read cache-first then refresh (offline tolerance,00-shared/10§2). - Ref resolution (route detail): vehicle/driver ids are raw ObjectIds
(
route.schema.ts:26-30); RouteDetailCubit resolves via cached lists orGET /transport/vehicles|drivers/:idin parallel; unresolved -> null. - Events as hints:
VehicleCreated/RouteCreated/StudentRouteAssigned(transport.service.ts:47-57, 121-128, 267-278) are server-side signals; client refresh is response-driven, not event-driven. - Permission gating: cubits expose
canCreate/canUpdate/canDeletefrom RBAC (permissions.constants.ts:62-74); UI hides actions accordingly. - Planned cubits: LiveTracking/Attendance/Fees
(planned)perIMPLEMENTATION_PLAN.md:229- no data contract yet.
14 - QA Checklist (Transport Module)
- 1. Core flows (must pass)
- 2. Validation & conflicts (negative paths)
- 3. Integrity gaps (flag to backend - NOT server-enforced)
- 4. Soft-delete & reference integrity
- 5. Multi-tenancy
- 6. UI/UX (manual + golden)
- 7. E2E regression seeds (manual fixtures)
Module-specific quality gates on top of 00-shared/10 (QA baseline: performance budgets, offline matrix, a11y, motion, security). Every item is testable and maps to source.
1. Core flows (must pass)
| # | Scenario | Expectation | Source |
|---|---|---|---|
| 1.1 | Create vehicle -> appears in list | POST 201, doc in GET list, sorted by plateNumber | transport.controller.ts:30-40, transport.service.ts:71 |
| 1.2 | Create route with 3 stops -> detail shows order 1,2,3 | stops array round-trips {name, order} | create-route.dto.ts:34-39, route.schema.ts:23-24 |
| 1.3 | Assign student -> student assignments shows populated route | POST + GET /assignments/:studentId | transport.controller.ts:120-130, route-assignment.repository.ts:20-24 |
| 1.4 | Soft delete vehicle -> list excludes it | isDeleted: true, filter base.repository.ts:20-30, 68-74 | transport.service.ts:100 |
| 1.5 | Update driver -> PATCH applies partial fields only | untouched fields unchanged | update-driver.dto.ts:4, transport.service.ts:224-238 |
2. Validation & conflicts (negative paths)
| # | Scenario | Expectation | Source |
|---|---|---|---|
| 2.1 | Create vehicle with duplicate plateNumber | 409, message verbatim Vehicle "<plate>" already exists. | transport.service.ts:38-45; index vehicle.schema.ts:50 |
| 2.2 | Create route with duplicate name | 409 Route "<name>" already exists. | transport.service.ts:113-116; index route.schema.ts:44 |
| 2.3 | Create driver with duplicate licenseNumber | 409 Driver with license ... | transport.service.ts:177-184; index driver.schema.ts:50 |
| 2.4 | Create driver with duplicate phone | 409 Driver with phone ... | transport.service.ts:185-190; index driver.schema.ts:51 |
| 2.5 | Assign same (route, student) twice | 409 Student already assigned to this route. | transport.service.ts:252-258; index route-assignment.schema.ts:42-45 |
| 2.6 | capacity = 0 or negative | 400 (DTO @Min(1)) | create-vehicle.dto.ts:14-17 |
| 2.7 | shift not in morning/evening/both | 400 (IsEnum) | assign-route.dto.ts:13-15 |
| 2.8 | PATCH with invalid type value | 400 (enum) | update-vehicle.dto.ts:4 -> create-vehicle.dto.ts:19-21 |
| 2.9 | Unauthenticated call | 401 | transport.controller.ts:23-26 |
3. Integrity gaps (flag to backend - NOT server-enforced)
| # | Gap | Client mitigation | Source |
|---|---|---|---|
| 3.1 | Capacity overflow: assignStudent never checks route vehicle capacity vs. count of students | client pre-check warn + QA manual test; server check (planned) | transport.service.ts:251-266, vehicle.schema.ts:27-28 |
| 3.2 | Assign to inactive route / maintenance vehicle / inactive driver not blocked | client filters inactive options; server (planned) | route.schema.ts:32-33, vehicle.schema.ts:33-34, driver.schema.ts:30-31 |
| 3.3 | Duplicate plate race (no DB unique index? index exists: {tenantId, plateNumber} unique - race window only via concurrent create) | rely on 409; retry UX | vehicle.schema.ts:50 |
| 3.4 | Updating a vehicle's plateNumber to an existing one -> 409 only via unique index (service check is create-only) | expect 409 from index; surface verbatim | transport.service.ts:84-91, vehicle.schema.ts:50 |
| 3.5 | Driver licenseExpiry in the past accepted | client warn; server (planned) | create-driver.dto.ts:27-29 |
4. Soft-delete & reference integrity
| # | Scenario | Expectation | Source |
|---|---|---|---|
| 4.1 | Delete vehicle referenced by a route | 409 Vehicle is assigned to a route. | transport.service.ts:94-99 |
| 4.2 | Delete driver referenced by a route | 409 Driver is assigned to a route. | transport.service.ts:241-246 |
| 4.3 | Delete route with active assignments | 409 Route has active student assignments. | transport.service.ts:168-171 |
| 4.4 | Delete already-deleted entity | 404 (scoped filter excludes deleted) | transport.service.ts:101, 173, 248, 290, base.repository.ts:20-30 |
| 4.5 | Delete assignment -> removed from student list | GET reflects | transport.service.ts:288-291 |
5. Multi-tenancy
| # | Scenario | Expectation | Source |
|---|---|---|---|
| 5.1 | Tenant B queries vehicles | sees only B's rows | base.repository.ts:20-30 |
| 5.2 | Cross-tenant duplicate plate allowed | yes - uniqueness is per tenant | vehicle.schema.ts:50 |
| 5.3 | tenantId from request body ignored | server never reads it from body | base.repository.ts:32-36, AGENTS.md |
6. UI/UX (manual + golden)
| # | Check |
|---|---|
| 6.1 | Pagination boundaries: page 1 Prev disabled; last page Next disabled (meta hasNext/hasPrevious) |
| 6.2 | 409 inline errors focus the offending field |
| 6.3 | Delete 409 dialog offers "View related" navigation |
| 6.4 | Stop reorder persists after save + reload |
| 6.5 | Offline: lists render cached + banner; mutations blocked (00-shared/10 §2) |
| 6.6 | Empty states for all 4 lists (vehicles/drivers/routes/assignments) |
| 6.7 | Permission gating: read-only role sees no FAB/menu; 403 copy on direct route |
| 6.8 | a11y: badges have semantics labels; dialogs trap focus (00-shared/09) |
| 6.9 | Skeleton < 200 ms, content < 2 s on network (00-shared/10 §1) |
7. E2E regression seeds (manual fixtures)
- 2 tenants with identical plate numbers (proves isolation, 5.2).
- Route with 20 stops (editor cap), 3 assignments on one route.
- One vehicle in
maintenance, one driveron_leave, one routeinactive. - Deleted vehicle referenced by a soft-deleted route (delete guard must still 409 while route visible to admin).
15 - Flutter Implementation Guide (Transport Module)
- 1. Feature folder
- 2. Models (mirror schemas exactly)
- 3. Repositories (dio)
- 4. Cubits
- 5. Screens
- 6. Validation (client mirror of DTOs)
- 7. Navigation (go_router)
- 8. DI registration (get_it)
- 9. Permissions & auth
- 10. Testing
- 11. Forward-looking hooks
Module-specific build guide on top of 00-shared/11 (Flutter architecture, get_it DI, dio, go_router, flutter_bloc) and 00-shared/06 (state patterns). Order = recommended implementation sequence; each step maps to source files.
1. Feature folder
lib/features/transport/
data/
dto/vehicle_dto.dart, driver_dto.dart, route_dto.dart,
route_assignment_dto.dart # envelope-payload mappers
repositories/vehicle_repository.dart, driver_repository.dart,
route_repository.dart, assignment_repository.dart
domain/ # thin models (Vehicle, Driver, Route,
models.dart # RouteStop, RouteAssignment, Shift)
presentation/
cubits/vehicle_list_cubit.dart, vehicle_form_cubit.dart,
driver_list_cubit.dart, driver_form_cubit.dart,
route_list_cubit.dart, route_form_cubit.dart,
assignment_cubit.dart, transport_overview_cubit.dart
screens/overview_screen.dart, vehicles_list_screen.dart,
vehicle_detail_screen.dart, drivers_list_screen.dart,
driver_detail_screen.dart, routes_list_screen.dart,
route_detail_screen.dart, route_editor_screen.dart,
assign_student_sheet.dart, student_assignments_screen.dart
widgets/stat_tile.dart, entity_status_badge.dart, shift_picker.dart,
route_stop_editor.dart, resource_picker.dart,
assignment_tile.dart, dependency_guard_dialog.dart,
conflict_field_error.dart
Widgets per 07_Component_Library.md; screens per 05/06.
2. Models (mirror schemas exactly)
Vehicle-vehicle.schema.ts:19-50: plateNumber, model, capacity (int), type (bus/van/car enum), status (active/maintenance/inactive), year?, color?, insuranceExpiry? (DateTime, server sends string - parse defensively), notes?.Driver-driver.schema.ts:13-47: firstName, lastName, licenseNumber, phone, email?, status (active/inactive/on_leave), licenseExpiry?, address?, emergencyContact?, joinedAt? (DateTime), notes?.Route-route.schema.ts:12-40: name, startPoint, endPoint, stopsList<RouteStop{name, order}>(keep sorted by order client-side), vehicleId?, driverId? (String ObjectIds), status, estimatedDuration?, notes?.RouteAssignment-route-assignment.schema.ts:12-38: routeId (populated -> Route), studentId, shift (morning/evening/both), status, stopName?, assignedAt? (DateTime), notes?.
Immutable classes + fromJson/toJson (repository maps envelope data).
Never send tenantId/isDeleted (server-owned, base.repository.ts:32-36).
3. Repositories (dio)
- Endpoints per
12_API_Mapping.md; base/api/v1+ApiBearerAuth(transport.controller.ts:23-26). - Paginated calls: parse
data+meta {page, limit, totalItems, totalPages, hasNext, hasPrevious}(buildPaginationMeta,transport.service.ts:75). - Typed errors:
ApiException(409, message)for conflicts (message verbatim - used directly inConflictFieldError);ApiException(404). - Assignment repo:
findByStudent(studentId)expectsrouteIdpopulated (route-assignment.repository.ts:20-24) - map nested route. - Single-flight + envelope mapping per
00-shared/11§"AppDio".
4. Cubits
Per 13_State_Management.md: implement VehicleListCubit first (pattern for
the other two lists), then form cubits (with conflicts map), then
AssignmentCubit (assign + student list), then TransportOverviewCubit.
Use the shared LoadState sealed class and pagination mixin
(00-shared/06 §3.1-3.2).
5. Screens
| Order | Screen | Key widgets | Source |
|---|---|---|---|
| 1 | Vehicles list/detail/form | EntityStatusBadge, ConflictFieldError, AppPagination | 06 §2-4 |
| 2 | Drivers list/detail/form | same + AppAvatar | 06 §5-7 |
| 3 | Routes list/detail | ResourcePicker (read), stop list | 06 §8-9 |
| 4 | Route editor | RouteStopEditor (ReorderableListView + order resequence), ResourcePicker | 06 §10 |
| 5 | Assign sheet + student assignments | ShiftPicker, StudentPicker, AssignmentTile | 06 §11-12 |
| 6 | Overview | StatTile x3 | 06 §1 |
6. Validation (client mirror of DTOs)
- Vehicle form: capacity
>= 1(create-vehicle.dto.ts:14-17); type from enum (:19-21). - Driver form: email
IsEmail-style regex (:22-24); datesIsDateString-> send ISO strings (:27-29, 41-44). - Route form: stops
{name, order}(create-route.dto.ts:11-19); optional vehicleId/driverId strings. - Assign: shift enum (
assign-route.dto.ts:13-15). - Conflict messages: render server text verbatim (
07§8).
7. Navigation (go_router)
/transport overview (auth + any transport.* read)
/transport/vehicles list | children: /:id detail
/transport/drivers list | children: /:id detail
/transport/routes list | children: /:id, /:id/edit
/transport/students/:studentId/transport assignments
Create forms = bottom sheets (phone) / dialogs (tablet), not routes
(00-shared/03). Planned: /transport/live, /transport/attendance,
/transport/fees placeholders (planned).
8. DI registration (get_it)
getIt.registerLazySingleton<VehicleRepository>(() => VehicleRepository(getIt()));
getIt.registerLazySingleton<DriverRepository>(() => DriverRepository(getIt()));
getIt.registerLazySingleton<RouteRepository>(() => RouteRepository(getIt()));
getIt.registerLazySingleton<AssignmentRepository>(() => AssignmentRepository(getIt()));
// cubits factory-registered per screen (pass params via constructor args)
9. Permissions & auth
- Gate FAB/menu on RBAC (
permissions.constants.ts:62-74); 403 handling per00-shared/07; JWT viaAppDiointerceptor (00-shared/11). - Note: RBAC guards on endpoints are not yet wired in the backend
(
AGENTS.md"Not yet implemented") - client still gates UI.
10. Testing
- Unit: cubit state transitions incl. 409 ->
Conflict, optimistic delete rollback (13§2-4); modelfromJsonfor populated assignment. - Widget: golden per screen (
00-shared/10§6); stop editor reorder test. - Integration: mock dio with fixture envelopes; pagination boundary tests
(
meta.hasNext). - E2E seeds per
14§7.
11. Forward-looking hooks
- Live tracking/attendance/fees screens scaffolded behind feature flags,
wired when backend lands (
IMPLEMENTATION_PLAN.md:229). - QR boarding
(forward-looking):mobile_scannerdependency flagged in00-shared/11§1. - Analytics events
transport.*(proposed)(05Analytics section).
01 — Product Overview (Users Module)
- 1. What the module is
- 2. Scope in / scope out
- 3. Status / lifecycle model
- 4. Bulk import — two real paths
- 5. Invites, roles, and the RBAC bridge
- 6. GDPR & data-retention posture
- 7. Platform & client scope notes
- 8. Goals (product)
- 9. Non-goals (per source)
Canonical identity and profile management. Derived from
studylyon-blueprint/04-Modules/Users.md,src/modules/users/**, the auth module (auth-account.schema.ts,auth.service.ts), RBAC (organization-member.schema.ts), the bulk module (bulk-import.service.ts),email.worker.ts,event-queue-map.ts,PLAN.md, andstudylyon-blueprint/03-Database/COLLECTIONS.md. Nothing in this doc is invented; plan-only capability is marked(planned), client-only or roadmap-only capability(forward-looking), analytics(proposed).
1. What the module is
The Users module owns canonical identity for every person in a tenant:
users stores profile data (name, contact, avatar, gender, DOB, language,
timezone), notification/theme preferences, and a lifecycle status.
It does not own authentication credentials (those live in auth_accounts),
does not own business profiles (teacher/student/parent/staff profile rows
reference users._id), and does not own role assignments (those live on
organization_members).
"
usersstores identity only — never passwords, attendance, or academic data." (studylyon-blueprint/04-Modules/Users.md:63)
Key facts from source:
usersschema:user.schema.ts:14-79— status enumactive | inactive | suspended | invited(user.schema.ts:7-12, defaultactive:46-47); unique(tenantId, email)(:83) and unique partial(tenantId, phone)(:84-90); indexes on(tenantId, status)and(tenantId, displayName)(:91-92).- Every business document (including users) carries
tenantId, soft-delete flagsisDeleted/deletedAt/deletedBy, audit authors, and an optimistic-lockversion(base.schema.ts:9-31). All queries are auto-scoped (base.repository.ts:20-30). - Events:
UserCreated,UserUpdated,UserDeleted(events/user-events.ts:1-27); routingevent-queue-map.ts:10-12(UserCreated→ in-app notification;UserUpdated/UserDeleted→ audit-write). - Permissions exist in
permissions.constants.ts:6-10:user.read,user.create,user.update,user.delete,user.import(plusrbac.member.*:15-18for membership management).
2. Scope in / scope out
| In scope (implemented) | Out of scope (owned elsewhere) |
|---|---|
| User CRUD (create, list, get, patch, soft-delete) | Passwords / auth credentials → auth_accounts (auth-account.schema.ts:10-56) |
| Preferences (notifications email/push/sms, theme light/dark/system + language) | Roles & memberships → organization_members (organization-member.schema.ts:13-44) |
| Avatar upload via StorageProvider | Teacher/student/staff/parent profile data → their modules (RELATIONSHIPS.md:13,22-32) |
| GDPR erasure endpoint + scheduled hard purge | Multi-channel notifications → Notifications module |
Bulk CSV import (inline /users/import) | Invite acceptance / set-password flow → (planned) (no endpoint in code) |
| RBAC member management (used as the "invite" wiring) | Analytics → (proposed) (no SDK chosen, 00-shared/12 A4) |
3. Status / lifecycle model
3.1 User status (user.schema.ts:7-12)
| Status | Meaning | Set by |
|---|---|---|
active | Default on create (user.schema.ts:46-47; also CreateUserDto default create-user.dto.ts:44-47) | create / PATCH status |
inactive | Deactivated, can be re-activated via PATCH | PATCH status |
suspended | Suspended (e.g. discipline); re-activatable | PATCH status |
invited | Awaiting acceptance — enum exists, no endpoint sets it today | (planned) invite flow |
3.2 Soft-delete lifecycle
DELETE /users/:id→softDelete()setsisDeleted:true, deletedAt, deletedBy(base.repository.ts:68-74;users.service.ts:173-184) and emitsUserDeleted→audit-writequeue (event-queue-map.ts:12).- Soft-deleted users are invisible to every scoped query
(
base.repository.ts:20-30) and cannot log in — login usesfindByEmailUnscopedwhich filtersisDeleted:false(users.repository.ts:21-25,auth.service.ts:124). - Hard purge:
TENANT_PURGEworker deletes all soft-deleted docs older than 30 days (tenant-purge.worker.ts:15,32-43) — idempotent (only docs alreadyisDeletedbefore the cutoff). - GDPR erasure (
POST /users/:id/erasure,users.controller.ts:69-76): anonymizes PII in place (firstName=Erased,lastName=User,displayName=Erased User,email=erased-<id>@anonymized.invalid,isDeleted=true—users.service.ts:187-197) and enqueues agdpr-erasurejob on thetenant-purgequeue (attempts: 3, exponential backoff 5000 ms —users.service.ts:200-209); the worker hard-deletes the user doc (tenant-purge.worker.ts:49-56).
3.3 "Invited" status — what exists vs what is missing
Exists in code:
UserStatus.INVITEDenum (user.schema.ts:11).MemberStatus.INVITED+invitedBy+acceptedAtonorganization_members(organization-member.schema.ts:7-11,33-38).auth_accounts.emailVerificationToken+resend-verificationendpoint (auth-account.schema.ts:39-40;auth.controller.ts:83-89) — the closest live mechanism: a created user gets a verification email with a token (email.worker.ts:26-32), but only on self-registration (auth.service.ts:101-118), not on admin-created users.
Missing in code (hence (planned)):
- No endpoint sets
status=invited;UsersService.createalways leaves the DTO default (create-user.dto.ts:44-47). - No invite email (email worker handles only
UserRegisteredandPasswordResetRequested—email.worker.ts:25-42; noUserInvitedhandler, noevent-queue-maproute). - No accept-invite / set-password endpoint.
Product conclusion: the "invite" journey is designed below as the
create-user + POST /rbac/members combination, with the acceptance e-mail
explicitly (planned); UI must not promise an email that the backend does not
send yet (see 12_API_Mapping.md E5 note and 14_QA_Checklist.md).
4. Bulk import — two real paths
There are two distinct CSV import implementations in source. Both are synchronous; there is no async job and no result-polling endpoint today:
| Path | Endpoint | Parser | Report shape | Errors shape |
|---|---|---|---|---|
| A. Users inline | POST /api/v1/users/import (multipart field file) — users.controller.ts:104-110 | naive split(',') line parser (users.service.ts:236-252) | {imported, errors} | flat string[] — Row N: message (users.service.ts:281) |
| B. Bulk module adapter | POST /api/v1/bulk/import/:entity — bulk.controller.ts:35-48 | csv-parse/sync (quoted fields, trim, skip empty) — bulk-import.service.ts:26-30 | ImportReport {entity, totalRows, imported, failed, errors} | {rowNumber, errors[]} (import-adapter.interface.ts:14-25) |
- Path B today ships one adapter:
students(students-import.adapter.ts:15), which internally creates users viaUsersService.createfor each row (students-import.adapter.ts:66-71). There is nousersadapter — the service throwsNo import adapter for entity "users"(bulk-import.service.ts:17-20).(planned): register a users adapter in the bulk module (the service explicitly notes the registry is deferred:bulk-import.service.ts:15-16). - Path A rules (exact,
users.service.ts:250-279): header row required + ≥ 1 data row (:238-243); headers lowercased/trimmed (:244-247); per row — missing email → error; email already exists → error; create withfirstName=firstname|first_name|'Unknown',lastName=lastname|last_name|'Unknown',phone,gender,language(defaulten),timezone(defaultUTC). - Path A imports do not emit
UserCreatedevents (rows callrepo.createdirectly —users.service.ts:264-272), unlike single create.PLAN.md 2.7plans a queue/worker path (POST /api/v1/files/upload-csv→UserCreatedper row) —(planned).
5. Invites, roles, and the RBAC bridge
"Inviting" someone into the tenant is a two-document operation today:
POST /api/v1/userscreates the identity (statusactiveby default).POST /api/v1/rbac/members{userId, roles[]}creates the membership (rbac.controller.ts:63-67;rbac.service.ts:113-127— status hard-setACTIVE,joinedAtnow;MemberStatus.INVITEDexists in schema but is not used by the service).
Membership is what grants roles; roles carry permissions
(rbac.service.ts:59-65; permission cache in Redis TTL 300 s :44-73).
The users screen must therefore be built as a user + membership composite
— roles shown on user rows come from GET /rbac/members
(rbac.controller.ts:57-61), not from GET /users.
6. GDPR & data-retention posture
- PII (email, phone, avatar) lives in
users(studylyon-blueprint/03-Database/DATA_RETENTION.md:69); retention: life of tenancy, soft-delete (DATA_RETENTION.md:22). - Erasure: anonymize + soft-delete immediately, hard-delete via purge job
(
users.service.ts:186-210). - Blueprint: "PII masked in audit snapshots" (
Users.md:65) and "deleting a user cascades (async) to dependent profiles" (Users.md:67) — cascade is(planned)(no cascade code; the purge worker deletes the user doc only,tenant-purge.worker.ts:49-56).
7. Platform & client scope notes
- Mobile client is a forward-looking spec. The PRD explicitly excludes
native mobile apps from Phase 1 ("Native mobile apps (web-first)",
PRODUCT_REQUIREMENTS_DOCUMENT.md:144); the shared ledger flags the whole Flutter design set as forward-looking (00-shared/12 A1). All screens, routes, and Flutter implementation guidance in this doc set target the web-first responsive client (web/desktop/tablet/phone layouts) and are(forward-looking)by extension. - API is
v1, Bearer JWT, tenant from token only (00-shared/07 §1,§6); wire contract and error codes per 00-shared/07 §2-§3 (success{success:true,message:"OK",data,meta?,timestamp,requestId}; codes 400/401/ 403/404/409/422/429/5xx).
8. Goals (product)
- Admin can see and manage every person in the tenant — searchable, paginated list; detail; edit; deactivate; erase (GDPR).
- Bulk onboarding — CSV upload with per-row error reporting so a 1,000-row file can be fixed and re-imported row-by-row without data loss.
- Self-service profile & preferences — user edits own display data, notification toggles, theme.
- Status lifecycle clarity —
active / inactive / suspended / invitedsurfaced with unambiguous copy; soft-delete as the safe default removal. - Invite path — create identity + membership, then invite acceptance
(planned).
9. Non-goals (per source)
- No passwords / auth flows in this module (auth module owns them).
- No profile photos stored in MongoDB — delegated to
StorageProvider(users.service.ts:217-222). - No role/permission editing here — RBAC module.
- No async import job yet — import is request-synchronous.
- No analytics instrumentation in this module
(proposed).
02 — User Personas (Users Module)
Persona base from
studylyon-blueprint/01-Product/USER_PERSONAS.md, refined for the Users module. Each persona lists module-specific goals, pains, and the exact capabilities the backend gives them (withfile:line).
1. Tenant personas acting on users
P1 — Org Admin (the user manager)
"Manages a single institution (tenant)... Goals: configure branches, invite staff, oversee operations." (
USER_PERSONAS.md:22-25)
- Role in this module: primary operator of every Users screen.
- Permissions granted by code:
user.read/create/update/delete/import+rbac.member.*exist in the permission catalogue (permissions.constants.ts:6-10,15-18); register seeds theorg_adminrole (auth.service.ts:84-99). - Goals
- Create a user (staff, teacher, parent) in seconds —
POST /users(users.controller.ts:36-40). - Find anyone:
GET /users?q=regex over first/last name, email, displayName, case-insensitive (users.service.ts:92-99). - Bulk-import a term-start spreadsheet:
POST /users/import(users.controller.ts:104-110) and get a per-row error list. - Grant/change roles:
POST/PATCH /rbac/members(rbac.controller.ts:63-73). - Deactivate or erase (GDPR):
DELETE /users/:id,POST /users/:id/erasure.
- Create a user (staff, teacher, parent) in seconds —
- Pains
- Duplicate emails are hard-rejected (409 —
users.service.ts:50-54,120-126); a typo blocks the whole row in import (Row N: email "x" already exists—users.service.ts:260-263). - No status/role filter on
GET /users(onlyq,sort, pagination —users.service.ts:90-115) → filters are(planned). - Import runs synchronously; a 1,000-row file blocks the request
(
users.service.ts:233-282) — see 14_QA_Checklist.md scenario C1.
- Duplicate emails are hard-rejected (409 —
- Success metric: add a staff member in < 60 s; import 500 rows with all errors locatable to a row number.
P2 — HR / Operations Staff (user manager delegate)
Staff persona: "operations, reception, coordinators... often read-heavy" (
USER_PERSONAS.md:34-39).
- Role: day-to-day user-list operator — new joiner intake, record cleanup,
status upkeep. Same screens as P1 but narrower permissions
(e.g.
user.read+user.create, nouser.delete). - Goals: search (name/email/displayName), view detail, update contact
info (
PATCH /users/:id), run the CSV import for new batches. - Pains: needs row-level role context on the users list (membership is a
separate API —
GET /rbac/members,rbac.controller.ts:57-61); without the join the list shows identities only. - Constraints enforced by code: soft-delete hides users from every list
(
base.repository.ts:20-30); if HR "removes" someone, they vanish — the UI must offer erasure vs. statusinactiveas distinct, labeled choices.
P3 — Teacher / Staff / Parent as managed users
Teacher: "wants minimal clicks; mobile-friendly" (
USER_PERSONAS.md:29-32); Parent: "wants proactive, timely communication" (USER_PERSONAS.md:47-50).
- Role: subject of admin management; consume self-service profile.
- What they can do today (self-service):
- Update their own profile data —
PATCH /users/:idaccepts name/email/ phone/gender/DOB/language/timezone/status/metadata (update-user.dto.ts:5-69). (Note: the controller does not restrict:idto self — client must enforce; server-side self-guard(planned), OQ-3.) - Preferences:
GET/PATCH /users/:id/preferences(users.controller.ts:78-93) — notification toggles and theme. - Avatar:
POST /users/:id/avatar(users.controller.ts:95-102).
- Update their own profile data —
- Pains
- Email change collides if the address is taken — 409 inline
(
users.service.ts:120-126). - Email verification state:
auth_accounts.emailVerifiedAtis written byverifyEmail(auth.service.ts:217-223) but the user-levelemailVerifiedflag on the schema (user.schema.ts:74-75) is never updated by that path — UI must not rely on it (OQ-4).
- Email change collides if the address is taken — 409 inline
(
P4 — Self-service user (any role)
- Same capabilities as P3 minus admin screens; cares about theme
(
preferences.theme.mode: light|dark|system—update-user-preferences.dto.ts:16-20), notification channels (email/push/smsbooleans —:8-12), and privacy. - Pain: no "account" endpoint that returns own profile — the client
resolves self by the JWT
suband callsGET /users/:id(OQ-3).
2. Platform persona
P5 — Super Admin (platform)
"Operates the StudyLyon SaaS itself... needs global visibility without touching tenant data" (
USER_PERSONAS.md:10-14).
- Role in this module: oversight only — user data stays tenant-scoped.
Platform admin bypasses tenant scope in repositories
(
base.repository.ts:21-23) butisPlatformAdminis a token flag; there is no platform users screen in the module surface (05_Global_IA has no platform section for users). Platform view of users(planned)if ever needed (audit use-case).
3. System personas
P6 — Email Engine (worker)
- Processes
emailsqueue (queue.constants.ts:2); handles onlyUserRegistered("Welcome to StudyLyon" + verify token) andPasswordResetRequested(email.worker.ts:25-42). - Consequence: admin-created users get no welcome/invite email today
(invite mail
(planned)); onboarding UI must say "account created" without promising an e-mail.
P7 — Purge Worker (GDPR)
tenant-purgequeue (queue.constants.ts:12); erases user doc ongdpr-erasurejob (tenant-purge.worker.ts:49-56); purges soft-deleted docs > 30 days (:32-43). Idempotent by design (:26).
P8 — Audit Logger
- Consumes
audit-writeforUserUpdated/UserDeleted(event-queue-map.ts:11-12); immutable perPLAN.md 19.3.
P9 — In-app Notifications
UserCreated→in-appqueue jobuser-created-notification(event-queue-map.ts:10) — admin gets notified of creations, but not of bulk-import rows (those bypass the event —users.service.ts:264-272).
4. Persona → screen map
| Persona | Screens (05_Screen_Inventory.md) | Permissions used |
|---|---|---|
| P1 Org Admin | Users list, detail, create, edit, bulk import, preferences (as manager) | user.*, rbac.member.* |
| P2 HR | Users list, detail, edit, bulk import | user.read, user.update/create |
| P3/P4 Teacher/Staff/Parent | Self-profile, preferences, avatar | self-scoped GET/PATCH /users/:id (client-enforced) |
| P5 Super Admin | none today (tenant data) | isPlatformAdmin |
| P6–P9 | none (background) | — |
Gap note: roles on the users list come from
GET /rbac/members(rbac.controller.ts:57-61), which requiresorg_adminrole (rbac.controller.ts:21-22) — a read-onlyuser.readuser cannot resolve role chips. Role display for non-admin viewers(planned).
03 — User Journeys (Users Module)
- 1. Invite a user (admin creates identity + membership)
- 2. Bulk CSV import (users) with progress
- 3. Edit profile & preferences (self-service)
- 4. Deactivate / soft-delete / GDPR erasure
- 5. Import failures & recovery
- Journey matrix (who / where / what)
End-to-end journeys computed from
users.controller.ts,users.service.ts,bulk.controller.ts/bulk-import.service.ts/students-import.adapter.ts,rbac.controller.ts,auth.service.ts, andtenant-purge.worker.ts. Each journey: entry, intent, decision points, system responses, failures, recovery, exit, permission denial, offline, conflict, multi-device, deep-link/push/email entry.(planned)/(forward-looking)per global rules.
1. Invite a user (admin creates identity + membership)
entry: /users → FAB "Add user"
intent: onboard a new staff member / teacher / parent with roles
sequenceDiagram
actor A as Org Admin / HR
participant F as CreateUserScreen
participant R as UsersRepository
participant API as POST /api/v1/users
participant RBAC as POST /api/v1/rbac/members
A->>F: firstName, lastName, email (+ optional middleName/displayName/phone/gender/DOB/language/timezone/status)
F->>R: submit()
R->>API: CreateUserDto — create-user.dto.ts:5-62
alt 201 success
API-->>R: user doc — users.service.ts:49-78 (displayName defaults to "firstName lastName" — :62)
Note over API: EventBus emits UserCreated → in-app queue job<br/>user-created-notification — event-queue-map.ts:10
R->>RBAC: {userId, roles: ["teacher"]} — rbac.service.ts:113-127 (status ACTIVE, joinedAt now)
F-->>A: success snackbar; row appears in list (refresh)
else 409 DUPLICATE_RESOURCE
API-->>R: "User with email X already exists." — users.service.ts:50-54
F-->>A: inline email conflict; suggest search; block submit until fixed
else 400 VALIDATION_ERROR
F-->>A: field errors (email format — create-user.dto.ts:24-26; gender enum :33-38; status enum :44-47)
else 429 / 5xx / offline
F-->>A: retry-able error; form draft kept
end
Note over F: Invite e-mail is (planned): email.worker.ts handles only<br/>UserRegistered / PasswordResetRequested (:25-42); no UserInvited event.
- Decision points: status (default
active—create-user.dto.ts:44-47); roles to assign (via RBAC step); whether to send verification email — today not sent for admin-created users (OQ-2). - Failures/recovery: email/phone dup → inline conflict (
users.service.ts:50-60); RBAC member create failing after user create → user exists without roles (compensate: retry membership; note OQ-5). - Exit: success → list (refresh); back → draft warning.
- Permission denial: no
user.create→ FAB hidden + 403 screen (client guard; server RBAC guard(planned)— onlyJwtAuthGuardtoday,users.controller.ts:31). - Multi-device: second admin sees the row on refresh; membership visible
via
GET /rbac/members. - Deep links / email entry:
(forward-looking)invite email with accept-link; no accept endpoint exists yet.
2. Bulk CSV import (users) with progress
entry: /users → "Import CSV" (bulk wizard)
intent: onboard N users from a spreadsheet
sequenceDiagram
actor A as Org Admin / HR
participant W as ImportWizard
participant P as CsvService (isolate parse + preview)
participant API as POST /api/v1/users/import (multipart file)
participant S as UsersService.bulkImport
A->>W: pick file (CSV) — desktop drag-drop
W->>P: parse in isolate → header map + row count + preview 5 rows
P-->>W: preview table, column mapping (firstname|first_name …)
A->>W: confirm → upload with progress bar (client-side stage progress)
W->>API: multipart field "file" — users.controller.ts:104-110
API->>S: buffer → utf-8 → lines — users.service.ts:236-237
Note over S: synchronous loop per row — :250-279 (min 2 lines :238-243; header lowercased :244-247)
S-->>API: {imported: N, errors: ["Row 3: missing email", …]} — :281
API-->>W: 201 envelope
W-->>A: result screen: N imported, M failed — expandable error list per row
alt some rows failed
A->>W: "Download errors" (client-side CSV) → fix → re-upload
end
- Progress semantics: the endpoint is synchronous — there is no
progress percentage from the server and no polling endpoint. The wizard's
progress bar reflects client-side stages (parse → upload → server processing
indeterminate) — honest labeling required (OQ-6). Async queue import with
polling
(planned)perPLAN.md 2.7(POST /api/v1/files/upload-csv→ per-rowUserCreated). - Validation facts shown in preview: required columns
email, firstname|first_name, lastname|last_name; optionalphone, gender, language, timezone(users.service.ts:264-271); within-file duplicate emails are caught sequentially (:260-263); quoted commas are not supported by the naive parser (:251—split(',')) — warn on quotes (OQ-7). - Partial-import semantics: rows are committed as they go; a failed row
does not roll back previous rows. Re-upload after fixes re-checks
duplicates, so already-imported emails are skipped with an error
(
:260-263) — user must delete those rows or accept the errors. - Idempotency: retrying the same file is safe (duplicates are rejected,
not duplicated) — but each retry returns the dupes as errors (see
14_QA_Checklist.md C2). - Timeout: for very large files the request may exceed gateway timeouts —
client guidance ≤ 1000 rows (see
14_QA_Checklist.md C1); upload timeout 120 s (00-shared/11 §5).
3. Edit profile & preferences (self-service)
entry: avatar menu → Profile (self) — or admin: user row → Edit
intent: update contact info / language / timezone / notification toggles / theme
sequenceDiagram
actor U as User (self) / Admin (managed)
participant F as EditUserScreen / PreferencesScreen
participant API as PATCH /api/v1/users/:id (+ /preferences)
participant S as UsersService.update / updatePreferences
U->>F: change firstName/lastName/email/phone/…
F->>API: UpdateUserDto — update-user.dto.ts:5-70
alt 200 success
S-->>F: updated doc; displayName recomputed if names changed — users.service.ts:136-139
Note over API: UserUpdated emitted with changes list — :145-153 → audit-write — event-queue-map.ts:11
F-->>U: success snackbar; audit trail continues in background
else 409 (email/phone taken by another user)
S-->>F: ConflictException — :120-134 → inline field error
else 404 (cross-tenant or erased id)
S-->>F: NotFoundException — :118,144 → "user not found" empty state
end
U->>F: Preferences tab → toggle email/push/sms, theme mode light|dark|system
F->>API: PATCH /users/:id/preferences — update-user-preferences.dto.ts:4-21
Note over S: preferences replaced wholesale ($set preferences: dto) — users.service.ts:161-163 — send full object!
- Critical contract: preferences are replaced (
$set: {preferences: dto},users.service.ts:161-163) — the client must PATCH the full merged preferences object; a partial payload wipes unmentioned sections. - Optimistic UI: safe toggles (theme, notification switches) may be
optimistic with rollback (
00-shared/06 §3.5); email/phone changes are never optimistic (conflict-prone,00-shared/07 §9). - Self vs managed: controller accepts any
:id; client restricts "self profile" tosub == :id(server-side self guard(planned), OQ-3). - Avatar: separate multipart flow
POST /users/:id/avatar(users.controller.ts:95-102); old avatar deleted best-effort (users.service.ts:227-229).
4. Deactivate / soft-delete / GDPR erasure
entry: user row → menu → "Deactivate" | "Delete user" | "GDPR erase"
intent: stop access; remove from lists; comply with right-to-erasure
sequenceDiagram
actor A as Org Admin
participant F as UserDetailScreen
participant API as DELETE /api/v1/users/:id
participant P as TenantPurgeWorker
actor E as Erased user
A->>F: Deactivate → PATCH /users/:id {status: inactive}
F->>API: status change — update-user.dto.ts:52-55
API-->>F: 200 (user can no longer be found by scoped queries? no — inactive ≠ deleted; login still possible if credentials exist)
A->>F: Delete → typed-confirm dialog (destructive, 05_Global_IA §5)
F->>API: DELETE /users/:id — users.controller.ts:62-67
API->>API: softDelete: isDeleted, deletedAt, deletedBy — base.repository.ts:68-74
Note over API: UserDeleted → audit-write — event-queue-map.ts:12
F-->>A: row removed; snackbar "Purged after 30 days"
A->>F: GDPR → POST /users/:id/erasure — users.controller.ts:69-76
API->>API: anonymize PII + isDeleted — users.service.ts:187-197
API->>P: gdpr-erasure job (attempts 3, backoff exp 5s) — :200-209
P-->>E: hard-delete user doc — tenant-purge.worker.ts:49-56
- Status vs delete distinction (must be explicit in UI):
inactive— record visible in admin lists, can be re-activated, can still log in if anauth_accountexists (auth.service.ts:124-137checks onlyisDeleted).- Soft-deleted — cannot log in (
findByEmailUnscopedfiltersisDeleted:false,users.repository.ts:21-25), invisible everywhere, hard-purged after 30 days (tenant-purge.worker.ts:32-43). - Erased — anonymized immediately, hard-deleted by job.
- Active sessions caveat: deleting/erasing a user does not revoke
existing JWTs/sessions (no session revocation call — OQ-8);
logout-allexists (auth.controller.ts:66-72) but is not called by user deletion. - Failures: 404 if already gone; network loss after confirm → retry is idempotent (DELETE is safe to retry).
- Permission:
user.deletefor delete; erasure uses the same permission surface today (no separate perm) — note in UI copy.
5. Import failures & recovery
entry: bulk wizard → result screen with errors → fix & re-run
sequenceDiagram
actor A as Org Admin
participant W as ImportResultScreen
participant API as POST /api/v1/users/import
A->>W: result: imported=97, errors=[{Row 3…}, {Row 14…}, …] — users.service.ts:281
W-->>A: error rows in a table (row number, message) — copy = backend text ("Row 3: missing email", "Row 3: email x@y already exists")
A->>W: download error CSV (client-side) → edit in Excel
A->>W: re-upload corrected file
W->>API: retry — same endpoint
alt duplicate of already-imported email
API-->>W: "Row N: email already exists" — re-verify before upload: keep previously imported rows out
end
- Recovery rules: partial imports are expected; the UI must never imply
atomicity (server commits row-by-row —
users.service.ts:250-279); error list is authoritative; cross-check imported count vs. expected. - Import via students adapter (bulk module) reports richer errors
{rowNumber, errors[]}+totalRows/imported/failed(import-adapter.interface.ts:14-25) and pre-validates refs (students-import.adapter.ts:38-60); entity selector in the wizard(planned)for ausersadapter (onlystudentsexists —bulk-import.service.ts:17-20). - Exit: result screen can be closed; import is already committed — no cancel path (synchronous).
Journey matrix (who / where / what)
| Journey | Entry | Primary screens | Backend truth |
|---|---|---|---|
| Invite user | /users FAB | CreateUser + RBAC roles sheet | POST /users, POST /rbac/members |
| Bulk import | /users "Import CSV" | ImportWizard (upload→preview→result) | POST /users/import (sync) |
| Edit profile/prefs | Avatar menu / row edit | EditUser, Preferences | PATCH /users/:id, PATCH /users/:id/preferences |
| Deactivate / delete / erase | Row menu / detail | Confirm dialogs | PATCH status, DELETE /users/:id, POST /users/:id/erasure |
| Import recovery | Result screen | Error table + re-upload | retry POST /users/import |
04 — Information Architecture (Users Module)
- 1. Navigation placement
- 2. Screen hierarchy (admin surface)
- 3. Screen hierarchy (self-service surface)
- 4. Modal hierarchy (per
00-shared/05 §5) - 5. Screen relationships
- 6. Routes & deep links
- 7. Nested nav & state survival
- 8. Quick actions, FABs, context menus
- 9. Global search integration
- 10. Filters & sorting (list semantics)
Module IA within the global shell (00-shared/05). The module owns the Users & Roles surface (
/users, 05_Global_Information_Architecture.md:47) for admin/HR, plus the account/profile self-service surface (05_Global_IA §8: avatar menu → profile, password, 2FA, devices, preferences). Routes are(planned)— no client repo exists; they followgo_routerconventions (00-shared/11 §6).
1. Navigation placement
| Surface | Destination | Shell slot | Visible to |
|---|---|---|---|
| Users management | /users | Settings group: "Users & Roles" (00-shared/05 §2) | Org Admin + HR/custom roles with user.read; full management needs user.create/update/delete/import |
| Roles & memberships | /roles | same group | org_admin (RBAC controller is org_admin-gated — rbac.controller.ts:21-22) |
| Self profile | avatar menu → /settings/profile | cross-cutting account surface (00-shared/05 §8) | all authenticated users |
- Role-gating note:
GET /rbac/membersrequiresorg_admin(rbac.controller.ts:21-22); if the Users list must show role chips to non-org_admin viewers, that data source is unavailable — role chips degrade to "—" for those viewers(planned)server-side join.
2. Screen hierarchy (admin surface)
/users ──────────────────────────── Users list (master)
├── /users/new ─────────────────── Create user (full-screen wizard-lite form)
├── /users/import ──────────────── Bulk import wizard (3 steps: upload → preview → result)
├── /users/:id ─────────────────── User detail (tabs: Profile | Membership | Preferences | Audit)
│ ├── /users/:id/edit ───────── Edit user (reuses create form w/ UpdateUserDto)
│ ├── /users/:id/preferences ── Preferences (manager view)
│ └── delete / erase ────────── (dialogs, not routes)
/roles ─────────────────────────── Roles & members list (RBAC module surface, referenced)
- Master-detail on tablet/desktop (≥ 840 dp,
00-shared/05 §3); push-on-top on phone; breadcrumbs on desktop at depth ≥ 2.
3. Screen hierarchy (self-service surface)
/settings/profile ──────────────── Self profile (read own GET /users/:id)
├── edit (inline page form)
├── preferences (notification toggles, theme mode)
└── avatar (upload dialog)
- Identity self-
:idresolution: JWTsubclaim (auth.service.ts:461); server-side self-guard(planned)(OQ-3).
4. Modal hierarchy (per 00-shared/05 §5)
| Level | Used for | Widget |
|---|---|---|
| Snackbar | create/update success, import summary, avatar saved, undo-less soft notes | AppSnackbar |
| Bottom sheet | role picker (from GET /rbac/roles), status picker, language/timezone pickers, row actions | AppBottomSheet |
| Dialog | deactivate confirm (consequence copy), delete confirm (typed), GDPR erasure confirm (typed, "ERASE"), avatar replace/remove | AppDialog |
| Full-screen | Create user, Edit user, Import wizard, Preferences, User detail | Page routes |
| Loader | only submit waits; screens prefer skeletons | AppProgress |
Destructive rules (00-shared/05 §5): delete needs typed confirm
("delete") — irreversible after 30-day purge; GDPR erasure needs typed confirm
("ERASE") — irreversible immediately (anonymized + purge job,
users.service.ts:186-209, tenant-purge.worker.ts:49-56).
5. Screen relationships
- List → Detail → Edit → back to Detail (refresh). List → Create → back to List (refresh). List → Import → Result → back to List (refresh).
- Detail tabs fetch from three sources:
GET /users/:id(profile),GET /rbac/members(membership — join byuserId),GET /users/:id/preferences(preferences). - Preferences manager view and self-profile preferences share the
same screen component (
PATCHtarget differs only by:id). - Status change happens from Detail (top-bar action) and from list row context menu.
6. Routes & deep links
| Route | Screen | Guard |
|---|---|---|
/users | Users list | authed + user.read |
/users/new | Create user | user.create |
/users/import | Import wizard | user.import |
/users/:id | User detail | user.read |
/users/:id/edit | Edit user | user.update |
/users/:id/preferences | Preferences | user.read + user.update |
/settings/profile (+ /preferences, avatar) | Self profile | authed (self :id) |
Deep links:
studylyon://users/:id— notification "user-created" → detail (UserCreated→ in-app jobuser-created-notification,event-queue-map.ts:10).studylyon://settings/profile— own profile ((forward-looking)push).
7. Nested nav & state survival
- Detail tabs use
TabBarViewkeep-alive (00-shared/03 AppTabs); switching bottom-nav destinations preserves tab state (00-shared/05 §3). - Import wizard state (file, preview, errors) survives screen navigation via
the
BulkImportCubit(13_State_Management.md §6) — never re-parse a file the user already validated unless they pick a new one.
8. Quick actions, FABs, context menus
| Screen | FAB | Quick action | Context menu (rows) |
|---|---|---|---|
| Users list | "Add user" (extended) | Ctrl+K search global; N new (desktop); "Import CSV" toolbar action | View · Edit · Deactivate/Activate · Delete · GDPR erase |
| User detail | — | Edit (top bar), status menu, erase (top-bar overflow) | — |
| Import wizard | — | primary CTA per step (Upload → Review → Start import) | — |
| Self profile | — | avatar camera, edit actions | — |
- FAB hidden while scrolling down / shown on scroll-up
(
00-shared/03 AppFAB); uniqueheroTagper screen. - Import entry point is deliberately a toolbar/empty-state action, not a
second FAB (one FAB rule,
00-shared/03 §D).
9. Global search integration
- Users list:
q→ regex on firstName/lastName/email/displayName, case-insensitive (users.service.ts:92-99), debounced 300 ms (AppSearchBar). - Global search backend indexing users
(planned)(search module exists as permissionsearch,permissions.constants.ts:93).
10. Filters & sorting (list semantics)
- Server-supported today: pagination
page/limit(1-100,20)/sort(±field)/q(pagination-query.dto.ts:5-30); default sort-createdAt(users.service.ts:103). - Status filter and role filter are
(planned)— no query params exist inusers.service.ts:90-115. Until then the client filter bar filters the loaded pages client-side (explicitly labeled, and honest about the limitation) or defers until server support lands.
05 — Screen Inventory (Users Module)
- S1 — Users list (master)
- S2 — Create user (wizard-lite form)
- S3 — User detail
- S4 — Bulk import wizard (upload → preview → result)
- S5 — Edit user
- S6 — Preferences (self + manager)
- S7 — Self profile
- S8 — Confirm dialogs (delete / erase / deactivate)
- S9 — Import result / error report
- Screen → endpoint matrix
Every screen the module owns, with purpose, entry, data sources, and states. Detailed specs in 06_Screen_Specifications.md. Screens are
(planned)(no client repo); data source citations are backend-exact.
S1 — Users list (master)
| Route | /users |
| Entry | Settings group → "Users & Roles" |
| Purpose | Find, filter, and act on every person in the tenant |
| Data source | GET /api/v1/users?page&limit&sort&q (users.controller.ts:42-46; users.service.ts:90-115) + GET /api/v1/rbac/members for role chips (rbac.controller.ts:57-61) |
| Query contract | q → $or regex on firstName/lastName/email/displayName (users.service.ts:92-99); default sort -createdAt (:103); paginated envelope meta {page,limit,totalItems,totalPages,hasNext,hasPrevious} (pagination-query.dto.ts:32-55) |
| States | Initial → skeleton rows; success+empty → AppEmptyState ("No users — add your first user"); error → AppErrorState (404 never for lists; 429 → backoff copy) |
| Key elements | AppSearchBar (debounce 300 ms) · filter chips row (status, role — (planned) server params, client-side filter meanwhile, 04_IA §10) · user rows: AppAvatar + name/displayName + email + status badge + role chips + last-login · pagination (infinite scroll on phone, page controls on desktop) |
| Actions | Row tap → S3 detail · row menu: Edit / Deactivate |
S2 — Create user (wizard-lite form)
| Route | /users/new |
| Entry | List FAB |
| Data source | POST /api/v1/users with CreateUserDto (create-user.dto.ts:5-62) + POST /api/v1/rbac/members after success |
| Elements | Identity section (firstName, middleName?, lastName, displayName?, email, phone?) · demographics (gender?, dateOfBirth?) · locale (language, timezone) · status (default active) · membership section (roles from GET /rbac/roles) |
| Submission | create user → on success immediately create membership; if membership fails show explicit "created without roles" banner + retry (OQ-5) |
| States | Form validation per field; 409 email/phone → inline conflict; success → snackbar + navigate to detail |
S3 — User detail
| Route | /users/:id |
| Data sources | GET /users/:id (users.controller.ts:48-53) · GET /rbac/members (join by userId) · GET /users/:id/preferences (:78-83) |
| Elements | Header: avatar + name + status badge + email + roles · tabs: Profile (all fields incl. lastLoginAt, emailVerified, phoneVerified, metadata) · Membership (roles + member status) · Preferences (manager view) · Audit (planned) (audit-write events exist — event-queue-map.ts:11-12; no read endpoint in scope) |
| Actions | Edit · status menu · Delete / GDPR erase (top-bar overflow) |
| States | 404 → "User not found" empty state (cross-tenant ids resolve 404 — base.repository.ts:20-30) |
S4 — Bulk import wizard (upload → preview → result)
| Route | /users/import |
| Entry | List toolbar "Import CSV" |
| Data source | POST /api/v1/users/import (multipart file, users.controller.ts:104-110); future bulk-module adapter path POST /api/v1/bulk/import/:entity (planned) for a users entity (bulk-import.service.ts:17-20) |
| Steps | 1 Upload — file picker (.csv only), drag-drop on desktop, size guard, template download · 2 Preview — client-side parse in isolate: header map, row count, first rows table, column mapping (firstname/first_name), warnings (missing optional cols, quotes detected) · 3 Result — imported/errors counts, error table Row N: message (users.service.ts:281), download-error CSV, "Import more" |
| Progress | stage-based client progress; server phase indeterminate (sync endpoint, 03_User_Journey.md §2) |
| States | Upload invalid file → inline error; malformed (no header/data) → server returns errors ["CSV must have a header row and at least one data row"] (users.service.ts:238-243); partial success → result screen |
S5 — Edit user
| Route | /users/:id/edit |
| Data source | PATCH /users/:id with UpdateUserDto (update-user.dto.ts:5-70) |
| Elements | same fields as S2 minus roles (roles edited in S3 Membership via PATCH /rbac/members/:id — rbac.controller.ts:69-73); email/phone conflicts → 409 inline (users.service.ts:120-134) |
| Notes | displayName recomputed server-side when names change (users.service.ts:136-139) — show resulting displayName |
S6 — Preferences (self + manager)
| Route | /settings/profile/preferences (self) · /users/:id/preferences (manager) |
| Data source | GET/PATCH /api/v1/users/:id/preferences (users.controller.ts:78-93) |
| Elements | Notifications: email/push/sms switches · Theme: mode radio `light |
| States | Load → skeleton; save → button spinner; 404 → not found |
S7 — Self profile
| Route | /settings/profile |
| Data source | GET /users/:id with :id = jwt sub (client-resolved; server self-guard (planned) — OQ-3) |
| Elements | avatar (POST /users/:id/avatar, users.controller.ts:95-102) · name/email/phone/gender/DOB/language/timezone · status read-only (self) · link to S6 preferences · link to password/2FA/sessions (auth module surface, auth.controller.ts:109-165) |
| Notes | emailVerified/phoneVerified read-only; see OQ-4 (user-level emailVerified flag not maintained by verifyEmail — auth.service.ts:217-223) |
S8 — Confirm dialogs (delete / erase / deactivate)
| Scope | Modal overlays on S1/S3 |
| Variants | Deactivate (plain confirm + consequence copy: still log-in capable if credentials exist) · Delete (typed confirm "delete"; 30-day purge note — tenant-purge.worker.ts:32-43) · GDPR erase (typed confirm "ERASE"; irreversible — anonymization users.service.ts:188-197 + purge job :200-209) |
S9 — Import result / error report
| Scope | Step 3 of S4 (also standalone after server-side error-only response) |
| Elements | headline counts (imported / failed), error table (row number, backend message), download-error-CSV, "Import more" |
| Copy rule | error strings rendered as-is from server (Row N: … — users.service.ts:256-278); prefixed with a static i18n title, never interpreted client-side |
Screen → endpoint matrix
| Screen | Endpoint(s) |
|---|---|
| S1 | GET /users · GET /rbac/members |
| S2 | POST /users · POST /rbac/members · GET /rbac/roles |
| S3 | GET /users/:id · GET /rbac/members · GET /users/:id/preferences |
| S4 | POST /users/import (future: POST /bulk/import/users (planned)) |
| S5 | PATCH /users/:id |
| S6 | GET/PATCH /users/:id/preferences |
| S7 | GET /users/:id · POST /users/:id/avatar |
| S8 | (client-only dialogs) |
| S9 | (client-only; feeds retry of POST /users/import) |
06 — Screen Specifications (Users Module)
- SS1 — Users list (
/users) - SS2 — Create user (
/users/new) - SS3 — User detail (
/users/:id) - SS4 — Bulk import wizard (
/users/import) - SS5 — Edit user (
/users/:id/edit) - SS6 — Preferences
- SS7 — Self profile
- SS8 — Confirm dialogs
- SS9 — Import result / error report
Full production specifications for the screens in 05_Screen_Inventory.md. Every field/behaviour is derived from backend DTOs/schemas; layout and copy follow the shared tokens/components (00-shared/02, 03, 04, 09). Screens are
(planned); backend behavior cited exactly.
SS1 — Users list (/users)
Layout (responsive)
Phone (<600dp) Tablet/Desktop (≥840dp)
┌──────────────────────────────┐ ┌───────────┬──────────────────────┐
│ AppBar: "Users" [ImportCSV]│ │ AppBar: "Users" [ImportCSV]│
│ SearchBar (debounce 300ms) │ │ SearchBar ──────────┐ │
│ [Filter chips: Status ▾][Roles▾] │ Filter chips │ Detail │
│ ┌─ ListTile row ────────────┐│ │ ┌─ Row 1 ─────────┐ │ pane │
│ │ avatar Name (displayName)││ │ │ ... │ │ (S3) │
│ │ email · status ▮ ││ │ └─────────────────┘ │ │
│ └───────────────────────────┘│ │ Row 2..N │ │
│ [infinite scroll spinner] │ │ [page controls] │ │
│ FAB: "Add user" │ │ │ │
└──────────────────────────────┘ └─────────────────────┴────────────┘
- Phone: list only; master-detail at ≥ 840 dp (selected row highlights,
00-shared/05 §3). - List rows: height ≥ 56 (
00-shared/03 AppListTile); avatar 40; leading avatar, title =displayName(fallbackfirstName lastName— always set server-side,users.service.ts:62), subtitle = email; trailing = status badge + overflow menu. - Role chips: from
GET /rbac/membersjoined client-side byuserId(rbac.controller.ts:57-61);org_admin-gated data source — chips show "—" for non-admin viewers (04_IA §1).
States
| State | Render |
|---|---|
| Initial/Loading | AppSkeleton list (8 rows) |
| Success+data | rows; footer "end of list" at last page |
Success+empty (q present) | AppEmptyState "No users match 'query'" |
Success+empty (no q) | AppEmptyState "No users yet" + primary CTA "Add user" |
| Error | AppErrorState per code: 429 → backoff copy; 5xx → generic + requestId (00-shared/06 §5) |
Filter chips
- Status chip: options map to
UserStatusenum exactly (active, inactive, suspended, invited—user.schema.ts:7-12). - Role chip: options from
GET /rbac/roles(rbac.controller.ts:27-31). - Server has no status/role query params (
users.service.ts:90-115); the chip filters the current client pages and is labeled "filtering loaded results" until server support lands ((planned), OQ-9). - Search
qis server-side (regex$or—users.service.ts:92-99); clearing restores list.
Refresh/pagination
- Pull-to-refresh resets to page 1, bypasses cache (
00-shared/03 §F). - Infinite scroll appends while
meta.hasNext(pagination-query.dto.ts:52); desktop uses explicit pager + jump-to-page.
A11y
- Row = one
Semantics(button)(avatar+name+status as summary); badge announced as "status: active" (00-shared/09 §7); live region announces "N results" after search (:44); filter chips announce selected state.
SS2 — Create user (/users/new)
Form structure (single full-screen page; >3 fields → page not sheet, 00-shared/05 §5)
Identity firstName* | middleName? | lastName* (one row on desktop, stacked on phone)
displayName? (helper: defaults to "firstName lastName" — users.service.ts:62)
email* (keyboardType email) | phone?
Demographic gender? (select: male|female|other|prefer_not_to_say — user.schema.ts:37-41)
dateOfBirth? (AppDatePicker, max = today)
Locale language (default en — user.schema.ts:49-50) | timezone (default UTC — :52-53)
Status status (default active — create-user.dto.ts:44-47; invited shown but selectable only in invite flow (planned))
Membership roles (multi-select chips from GET /rbac/roles) ← optional step; POST /rbac/members
- Field validation exactly mirrors
CreateUserDto(create-user.dto.ts:5-62): requiredfirstName,lastName,email(@IsEmail:24-26);genderis free string server-side (@IsString:33-38) but the client constrains to the schema enum;statusenum-validated (:44-47). - Submit flow:
POST /users→ on success, if roles selected →POST /rbac/members {userId, roles}(rbac.service.ts:113-127). Membership failure → keep user, show persistent banner "Created without roles — retry" (retry calls the same member POST; idempotent enough — member unique per(tenantId, userId)index,organization-member.schema.ts:48). - Email conflict (409): inline under email field, focus it
(
users.service.ts:50-54); suggest "Search existing users for 'x'". - Phone conflict (409): same treatment (
:56-60). - Anti-double-submit: button loading state replaces label
(
00-shared/08 §6); membership call is a second network round-trip — button stays "Creating…" through both.
A11y
- Labels visible + autofill hints (name, email, tel —
00-shared/09 §10); on submit failure focus moves to first invalid field; error summary announced via live region.
SS3 — User detail (/users/:id)
Layout
Header: [avatar 64] displayName [status badge] [⋯ menu]
email · phone · gender · DOB · language · timezone
lastLoginAt · emailVerified/phoneVerified (read-only)
Tabs: Profile | Membership | Preferences | (Audit (planned))
- Profile tab: all
UpdateUserDtofields read-only except through Edit (S5);metadatashown as JSON(proposed)collapsible — arbitrary object (user.schema.ts:68-69). - Membership tab: member status (
MemberStatus—organization-member.schema.ts:7-11), roles (editable chips →PATCH /rbac/members/:id,rbac.controller.ts:69-73),joinedAt,invitedBy,acceptedAt,lastActiveAt(:30-40); remove membership →DELETE /rbac/members/:id(:75-79). - Preferences tab: S6 embedded.
- Status change menu (header):
active→inactive|suspendedetc. — all four enum values selectable (user.schema.ts:7-12); confirm dialog forsuspendedwith consequence copy; the invited value selectable only via(planned)invite flow. - Delete / GDPR erase: top-bar overflow → S8 dialogs.
States
| Condition | Render |
|---|---|
| 404 (cross-tenant/erased/deleted id) | "User not found" empty state — do not leak existence (00-shared/07 §3) |
| Erased user visited post-purge | same 404 |
| No membership record | Membership tab empty-state "Not a member — add roles" |
SS4 — Bulk import wizard (/users/import)
Step 1 — Upload
- Drop zone (desktop) / file picker (phone); accept
text/csv, .csv; size guard: warn > 2 MB (server has no explicit limit in code — upload timeout 120 s client-side,00-shared/11 §5; OQ-10). - "Download template" → client-generated CSV with header
email,firstname,lastname,phone,gender,language,timezone(column names exactly as parsed: lowercased headers —users.service.ts:244-247; aliasesfirst_name/last_namesupported —:265-266). - Malformed file (binary, wrong ext) → inline error before upload.
Step 2 — Preview (client-side, isolate)
- Parse mirrors server semantics for preview fidelity:
- header row = first non-empty line, lowercase/trim (
users.service.ts:244-247); - rows = subsequent non-empty lines (
:237); naive,split (:251); - required:
email; name aliases optional with fallbackUnknown(:265-266); language/timezone defaultsen/UTC(:270-271).
- header row = first non-empty line, lowercase/trim (
- Show: column map, row count, first 5 rows; warnings: missing optional
columns; quotes present (server will split mid-quote — OQ-7); duplicate
email within file (server rejects row 2+ —
:260-263). - Client-side duplicate check against current list is advisory only (server re-checks at import time).
Step 3 — Result
- Counts:
imported/errors.length(exact fields of response —users.service.ts:281); progress bar 100% on response. - Error table: row number + message verbatim (
Row N: …—:256-278); paginate error list if > 50 (client-side). - Buttons: "Download errors (CSV)" (client-side export of row number + message), "Import more" (back to step 1), "Done" → list refresh.
- No cancel/rollback: server commits per-row (
:250-279); copy states "Already-imported rows are kept" on leaving.
Failure handling
- 400 envelope: response
datamay be absent; the endpoint returns{imported:0, errors:[…]}for empty files rather than a 400 (users.service.ts:238-243) — treat as result, not error. - Network loss mid-request: retry is safe (server dedups emails —
:260-263); inform "retry won't duplicate imported rows".
A11y
- Progress announced (
00-shared/09 §7"Importing users: 12%"); result summary in live region; error table rows readable by screen reader with row-number prefix.
SS5 — Edit user (/users/:id/edit)
- Same form as SS2 minus Membership; DTO =
UpdateUserDto(update-user.dto.ts:5-70) — all optional; submit sends only changed fields. - Email/phone change → server re-checks uniqueness (
users.service.ts:120-134); 409 inline. - After save:
displayNamemay change server-side (:136-139) — detail header refreshes from response. avatarFileIdfield exists in DTO (update-user.dto.ts:38-39) — not surfaced as a text field (managed via avatar upload S7/S3).
SS6 — Preferences
- Notifications: three switches
email,push,sms(update-user-preferences.dto.ts:8-12). - Theme: mode radio
light | dark | system+ language field (:16-20). - Save: full merged object (see contract warning in
03_User_Journey.md §3); switches optimistic with rollback; "Save" persists merged state. - Load:
GET /users/:id/preferences→user.preferences ?? {}(users.service.ts:168-171) — never null; empty state = defaults off.
SS7 — Self profile
- Read-only identity + avatar + links;
:idfrom JWTsub(auth.service.ts:461); guard(planned)server-side. - Avatar flow: pick → crop (square) →
POST /users/:id/avatarmultipartfile(users.controller.ts:95-102) → response{avatarFileId}(users.service.ts:230); old file deleted best-effort server-side (:227-229). Optimistic thumb while uploading; error → revert + snackbar. - Show
emailVerified/phoneVerifiedread-only — flagged OQ-4.
SS8 — Confirm dialogs
| Dialog | Trigger | Content | Confirm |
|---|---|---|---|
| Deactivate | row menu / header | "Set status to inactive? They can still sign in if they have credentials." | button "Deactivate" |
| Suspend | header | "They will see a suspension notice on login." (planned) copy — backend has no suspension-block copy (login rejects only lockout auth.service.ts:133-135) | "Suspend" |
| Delete | row menu / header | "User will be hidden immediately and hard-deleted after 30 days." | typed delete |
| GDPR erase | overflow | "PII is anonymized immediately; the account is permanently deleted. This cannot be undone." | typed ERASE |
SS9 — Import result / error report
- Standalone reuse of SS4 step 3 with server-only response (no preview): e.g. API clients pasting a direct multipart result.
- Render
errorsflat strings; count badgeerrorrole (00-shared/03 AppBadge).
07 — Component Library (Users Module)
- 1. Module component list
- 2. UserListTile
- 3. StatusBadge
- 4. RoleChips
- 5. FilterBar
- 6. AvatarUploader
- 7. ImportWizard
- 8. CsvPreviewTable
- 9. ImportErrorList
- 10. PreferencesPanel
- 11. PermissionScoped
- 12. Reused shared components (no new code)
- Golden-test expectation (00-shared/03 §G)
Module-specific components. Shared building blocks referenced from 00-shared/03_Component_Library.md are not redefined. All components are
(planned)(no client repo). Backend-derived constraints cited.
1. Module component list
| Component | Purpose | Builds on |
|---|---|---|
UserListTile | List row for S1 | AppListTile + AppAvatar + AppBadge |
StatusBadge | User status pill | AppBadge |
RoleChips | Role display/selection | AppChips |
FilterBar | Search + status/role chips | AppSearchBar + AppChips |
AvatarUploader | Avatar pick/crop/upload | AppAvatar + AppDialog |
ImportWizard | 3-step bulk import shell | AppStepper + AppProgress |
CsvPreviewTable | Parsed-row preview | table (00-shared/04 §8 desktop) |
ImportErrorList | Row-numbered error table | AppListTile dense + AppBadge |
TemplateDownloadButton | CSV template export (client-generated) | AppButton outlined |
PreferencesPanel | Notification/theme controls | AppSwitch + AppRadio + AppDropdown |
SelfProfileHeader | Avatar + identity header | AppAvatar + AppInfoRow |
PermissionScoped | Conditional render/guard wrapper | route guards (00-shared/05 §9) |
TypedConfirmDialog | Delete/erase typed confirm | AppDialog |
2. UserListTile
- Props:
user,roles?,onTap,onEdit,onDeactivate,onDelete,onErase,selected. - Render: avatar 40 (initials fallback — 00-shared/03 AppAvatar), title =
displayName(users.service.ts:62guarantees non-empty), subtitle =email, trailing =StatusBadge+AppMenu(View · Edit · Deactivate/ Activate · Delete · GDPR erase). - States: selected (master-detail), loading avatar placeholder.
- A11y: single button semantics; badge text included in summary.
3. StatusBadge
- Maps
UserStatusexactly (user.schema.ts:7-12):
| Status | Token | Icon+label (never color-only, 00-shared/09 §9) |
|---|---|---|
active | success/secondary | ✓ Active |
inactive | neutral onSurfaceVariant outlined | ◌ Inactive |
suspended | error/tertiary | ⊘ Suspended |
invited | primaryContainer | ✉ Invited |
- Unknown value (server could add) → neutral "Unknown" badge, no crash (forward-compat).
4. RoleChips
- Props:
roles[],onToggle,editable,loading. - Data:
GET /rbac/roles(rbac.controller.ts:27-31) — list mode shows member roles fromGET /rbac/membersjoined byuserId(rbac.controller.ts:57-61); empty when the viewer lacksorg_admin(that endpoint is role-gated:21-22) — show "—" with tooltip, never fake. - Selected chip =
secondaryContainer(00-shared/04 §2).
5. FilterBar
- Search:
AppSearchBar, debounce 300 ms, server-sideq(users.service.ts:92-99). - Status chip group (
StatusBadgevalues) + role chip group: client-side filter of loaded pages with explicit caption "(planned) server filter — applies to loaded results only" (04_IA §10, OQ-9). - Clear-all button resets chips + query.
6. AvatarUploader
- Flow: tap avatar → pick image (
file_picker) → square-crop dialog → multipartPOST /users/:id/avatar(users.controller.ts:95-102) withfield name = file(FileInterceptor('file'):96). - Response
{avatarFileId}(users.service.ts:230) — display via storage URL resolution(planned)(no URL from this endpoint; file id only). - States: uploading (avatar overlay spinner), error (revert + snackbar),
success (
HapticFeedback.lightImpact). - Size guidance: keep ≤ 2 MB (no backend cap in code — OQ-10).
7. ImportWizard
AppSteppersteps: 1 Upload → 2 Preview → 3 Result (00-shared/03 AppStepper).- Owns
BulkImportCubitstate (13_State_Management.md §6); back-nav from step 2 → step 1 keeps parsed file. - Destructive exit (leave mid-flow) → unsaved-changes dialog (parse state only; nothing server-side pending).
8. CsvPreviewTable
- Columns from parsed header (
users.service.ts:244-247); row numbers 1-based, header = row 1 — error rows areindex + 2(bulk-import.service.ts:46precedent) andRow Nin users import refers to the physical line (users.service.ts:257) — the preview must display physical line numbers to match error strings. - Highlight rows flagged client-side (missing email, dup email, quotes).
9. ImportErrorList
- Rows: physical row number (mono font, 00-shared/02 §2) + message
verbatim; badge count = errors length; "Download errors" button exports
row,messageCSV client-side. - Empty state when
errors.length === 0→ success check icon.
10. PreferencesPanel
AppSwitch× 3 (email/push/sms —update-user-preferences.dto.ts:8-12),AppRadiotheme mode × 3 (light|dark|system—:16-20),AppDropdownlanguage.- Save = merged full object (
users.service.ts:161-163replaces wholesale).
11. PermissionScoped
- Wrapper taking a permission string (e.g.
user.delete) → hides/rebuilds subtree; route-level equivalent viapermissionGuard(00-shared/11 §6). - Caution: server users controller currently enforces JWT only
(
users.controller.ts:31); RBAC guard on those routes is(planned)— the client still gates by permission so behavior is correct the day the guard lands (OQ-11).
12. Reused shared components (no new code)
AppSearchBar, AppChips, AppStepper, AppProgress, AppEmptyState,
AppErrorState, AppSnackbar, AppDialog, AppBottomSheet, AppMenu,
AppDatePicker, AppAvatar, AppBadge, AppListTile, AppInfoRow,
AppFAB, AppTabs, AppSkeleton.
Golden-test expectation (00-shared/03 §G)
UserListTile, StatusBadge (4 states), RoleChips, ImportErrorList,
PreferencesPanel at 3 sizes + light/dark.
08 — Form Specifications (Users Module)
- 1. Create user form (
POST /api/v1/users—CreateUserDto) - 2. Edit user form (
PATCH /api/v1/users/:id—UpdateUserDto) - 3. Preferences form (
PATCH /api/v1/users/:id/preferences—UpdateUserPreferencesDto) - 4. Avatar upload (
POST /api/v1/users/:id/avatar) - 5. Users CSV import columns (
POST /api/v1/users/import) - 6. Bulk-module CSV (
POST /api/v1/bulk/import/:entity) - 7. Form state conventions (client)
Every field of every form, derived 1:1 from
create-user.dto.ts,update-user.dto.ts,update-user-preferences.dto.ts, anduser.schema.ts, plus upload fields and CSV column specs fromusers.service.ts:233-282andstudents-import.adapter.ts:17-26. Validation column = server enforcement (class-validator / schema).
1. Create user form (POST /api/v1/users — CreateUserDto)
| # | Field | Type | Required | Server validation | Default | Notes |
|---|---|---|---|---|---|---|
| 1 | firstName | text | ✅ | @IsString (create-user.dto.ts:7-8) | — | Schema required: true (user.schema.ts:16-17) |
| 2 | middleName | text | — | @IsOptional @IsString (:10-13) | — | |
| 3 | lastName | text | ✅ | @IsString (:15-17) | — | Schema required (:22-23) |
| 4 | displayName | text | — | @IsOptional @IsString (:19-22) | firstName + " " + lastName (users.service.ts:62) | Recomputed on update (:136-139) |
| 5 | email | ✅ | @IsEmail (:24-26); schema lowercase+trim (user.schema.ts:28-29) | — | Unique per tenant (user.schema.ts:83); server 409 pre-check (users.service.ts:50-54) | |
| 6 | phone | tel | — | @IsOptional @IsString (:28-31); schema trim (:31-32) | — | Unique per tenant (partial index :84-90); 409 pre-check (users.service.ts:56-60) |
| 7 | gender | select | — | @IsOptional @IsString (:33-38) | — | Schema enum male/female/other/prefer_not_to_say (user.schema.ts:37-41); client constrains, server is string-typed (mismatch OQ-12) |
| 8 | dateOfBirth | date | — | optional, no decorator beyond Optional (:40-42) | — | Schema Date (:43-44); max = today |
| 9 | status | select | — | @IsEnum(UserStatus) (:44-47) | active | Enum active/inactive/suspended/invited (user.schema.ts:7-12); invited only via (planned) invite flow |
| 10 | language | text | — | @IsOptional @IsString (:49-52) | en | Schema default en (:49-50) |
| 11 | timezone | text | — | @IsOptional @IsString (:54-57) | UTC | Schema default UTC (:52-53); use searchable AppDropdown |
| 12 | metadata | json | — | @IsOptional object (:59-61) | — | Schema Object (user.schema.ts:68-69); not exposed as a user field by default (proposed) |
Submission semantics
- Tenant: never sent by client — derived from JWT (00-shared/07 §6;
repo injects
tenantId—base.repository.ts:32-36). - Success: 201 envelope with user doc; then (if roles chosen)
POST /rbac/members {userId, roles}(rbac.controller.ts:63-67). - Errors: 400 field-level; 409 email/phone (message text from
users.service.ts:50-60); 429 backoff; 5xx generic.
2. Edit user form (PATCH /api/v1/users/:id — UpdateUserDto)
| # | Field | Notes (same validation as §1 where present) |
|---|---|---|
| 1 | firstName / middleName / lastName | optional @IsString (update-user.dto.ts:8-24); renaming recomputes displayName (users.service.ts:136-139) |
| 2 | displayName | explicit override wins over computed (:136-139) |
| 3 | email | @IsEmail (:26-29); change → uniqueness re-check vs. existing (users.service.ts:120-126) |
| 4 | phone | @IsString (:30-33); change → uniqueness re-check (:128-134) |
| 5 | avatarFileId | @IsString (:38-39) — not a user-facing field; avatar managed via POST /users/:id/avatar |
| 6 | gender / dateOfBirth / status / language / timezone / metadata | same as §1 (:41-69); status enum-validated (:52-55) |
- Partial PATCH: only changed fields sent;
$setmerge +versionincrement (base.repository.ts:57-66).
3. Preferences form (PATCH /api/v1/users/:id/preferences — UpdateUserPreferencesDto)
| # | Group | Field | Type | Server validation | Notes |
|---|---|---|---|---|---|
| 1 | notifications | email | boolean | @IsOptional @IsObject group (update-user-preferences.dto.ts:4-12) | Schema Boolean (user.schema.ts:56-66) |
| 2 | notifications | push | boolean | same | |
| 3 | notifications | sms | boolean | same | |
| 4 | theme | mode | radio | @IsOptional @IsObject (:14-20) | Schema enum light/dark/system (user.schema.ts:61) |
| 5 | theme | language | text | same |
Contract warning: updatePreferences does $set: { preferences: dto }
(users.service.ts:161-163) — a full replace. Client always sends the
merged complete object; a partial payload wipes the other group (e.g.
sending only {theme} drops notifications).
Read shape: GET /users/:id/preferences → user.preferences ?? {}
(users.service.ts:168-171).
4. Avatar upload (POST /api/v1/users/:id/avatar)
| Field | Value |
|---|---|
| Multipart field name | file — FileInterceptor('file') (users.controller.ts:96) |
| Content type | multipart/form-data (ApiConsumes :97) |
| Payload | image buffer; filename prefix ${id}- server-side (users.service.ts:219) |
| Response | { avatarFileId } (:230); previous avatar deleted best-effort (:227-229) |
| Validation | none in code (no size/mime check server-side) — client: image types, ≤ 2 MB, square crop (OQ-10) |
| Storage path | sl/{tenantId}/avatars/{uuid} blueprint (04-Modules/Users.md:66) |
5. Users CSV import columns (POST /api/v1/users/import)
Endpoint parses rows keyed by lowercased header (users.service.ts:244-247):
| Column (accepted headers) | Required | Server behavior |
|---|---|---|
email | ✅ | missing → Row N: missing email (:256-259); duplicate (existing user or earlier row) → Row N: email "x" already exists (:260-263) |
firstname / first_name | — | fallback Unknown (:265) |
lastname / last_name | — | fallback Unknown (:266) |
phone | — | passed through if present (:268) |
gender | — | passed through (:269) |
language | — | default en (:270) |
timezone | — | default UTC (:271) |
- Header row required + ≥ 1 data row (
:238-243); empty file →errorscontains"CSV must have a header row and at least one data row". - Naive
,split — quoted commas unsupported (OQ-7); empty lines skipped (:237). - Success shape:
{ imported: number, errors: string[] }(:281). - Template download header (client):
email,firstname,lastname,phone,gender, language,timezone.
6. Bulk-module CSV (POST /api/v1/bulk/import/:entity)
- Parser:
csv-parse/sync{columns:true, skip_empty_lines:true, trim:true}(bulk-import.service.ts:26-30) — supports quoted fields; malformed → 400VALIDATION_ERROR(:31-33); no rows → 400 (:34-35). - Report shape:
{entity, totalRows, imported, failed, errors:[{rowNumber, errors[]}]}(import-adapter.interface.ts:14-25); rowNumber is physical line (index + 2, header = row 1 —bulk-import.service.ts:46). studentsadapter columns (students-import.adapter.ts:17-26): requiredfirstName, lastName, email, admissionNumber, grade, section, academicYear; optionalrollNumber. Validation: required presence (:41-45), email regex (:46-47), admission-number dup (:49-54), email registered dup (:55-56), ref resolution grade/section/academicYear/class (:58,100-144).- No
usersadapter yet —entity='users'→ 404 "No import adapter" (bulk-import.service.ts:17-20)(planned).
7. Form state conventions (client)
- Dirty tracking + unsaved-changes dialog (page forms, 00-shared/05 §5).
- Error display: server
VALIDATION_ERROR.detailsmapped to fields (00-shared/07 §3); 409 as inline conflict; focus first invalid field (00-shared/09 §10). - Anti-double-submit: all submit buttons disable + spinner (00-shared/08 §6).
09 — User Behaviour (Users Module)
- 1. Discovery & search behaviour
- 2. Create / duplicate handling
- 3. Status-change behaviour matrix
- 4. Soft-delete behaviour
- 5. GDPR erasure behaviour
- 6. Bulk import behaviour
- 7. Self-service behaviour
- 8. Role & membership behaviour
- 9. Concurrency & multi-device
- 10. Error-behaviour policy (per 00-shared/06 §5)
Behavioural rules: how users act on these screens, what the system must do in response, and the guardrails that keep admin operations safe. Backend facts cited; behavioural rules are product policy consistent with the API.
1. Discovery & search behaviour
- Search is server-side and instant-debounced.
qmatches firstName, lastName, email, displayName case-insensitively (users.service.ts:92-99). Users type partial names — no leading wildcard cost concern for tenant scale; debounce 300 ms (00-shared/03 AppSearchBar). - Typing in search while scrolled to page 7 resets to page 1 — standard paginated-search semantics (00-shared/06 §3.2).
- Result count announced (live region) per 00-shared/09 §7.
2. Create / duplicate handling
- Duplicate email is the #1 real-world error (import + manual). Product rule: on 409 the UI offers "Search existing users" — since the email already belongs to someone, the fix is often "re-activate that user" instead of creating a new row.
- Email is stored lowercase (
user.schema.ts:28-29) — the client lowercases previews/display so users aren't surprised by case changes. - Phone duplicates are blocked per tenant (partial unique index,
user.schema.ts:84-90) — same inline treatment as email.
3. Status-change behaviour matrix
| From → To | System behaviour | UI note |
|---|---|---|
any → active | PATCH status | re-enables login for inactive/suspended users (login checks only isDeleted + lockout — auth.service.ts:124-141) |
→ inactive | visible in admin lists | copy: "can still sign in if credentials exist" (no server block) |
→ suspended | visible; login allowed (no suspension block in code — OQ-13) | copy must not overpromise a login block |
→ invited | no server support ((planned) invite flow) | option hidden until invite endpoint exists |
- Never present status changes as "security lockdown" — the backend enforces
only
isDeletedand account lockout (auth.service.ts:133-135).
4. Soft-delete behaviour
- Deleted users vanish from all scoped queries (
base.repository.ts:20-30) and cannot log in (users.repository.ts:21-25). - Audit trail survives (
UserDeleted→audit-write,event-queue-map.ts:12) — but no user-side "recently deleted" view exists; re-creating with the same email works after deletion (no tombstones checked on create — only live docs,users.service.ts:50-54). UI copy: "Re-adding the same email creates a new account." - Purge after 30 days (
tenant-purge.worker.ts:32-43) — the delete dialog states this.
5. GDPR erasure behaviour
- Erasure is immediate and irreversible at the UI level (anonymized PII,
users.service.ts:188-197; hard delete by job:200-209,tenant-purge.worker.ts:49-56). - Product rule: erasure requires the user's written request context; the
typed-confirm dialog is the consent gate. Erased emails become
erased-<id>@anonymized.invalid(users.service.ts:192) — never shown in lists (doc is soft-deleted same step). - No undo; no "erase" on active users without the delete step being visible in the same dialog.
6. Bulk import behaviour
- Partial success is the norm; the result screen is the contract of
truth (
imported+errors—users.service.ts:281). - Users fix files iteratively: error CSV → spreadsheet → re-upload; each
round rejects already-imported emails (
:260-263) — the wizard must make clear "remove previously imported rows or accept these errors". - Never auto-dedupe on the client — server is authoritative.
- Rows import with defaults (
Unknownnames,en,UTC—users.service.ts:265-271) — preview must surface "Unknown" fallbacks before import so admins can fix names. - Bulk-imported users get no
UserCreatedevent (:264-272callsrepo.createdirectly) → no welcome notification. Behavior rule: result screen states "Accounts created; no emails sent" ((planned)invite emails).
7. Self-service behaviour
- Users edit their own profile via
PATCH /users/:id— client must scope tosub(server self-guard(planned), OQ-3). - Preference toggles: instant feedback (optimistic) but always submitted
as the full merged object (full-replace semantics,
users.service.ts:161-163). - Theme
systemdefault mirrors OS; changing language is a client-side locale switch on save (server stores the preference only).
8. Role & membership behaviour
- Roles are managed on the membership, not the user
(
organization-member.schema.ts:21-25;rbac.service.ts:113-127). - Removing a member (
DELETE /rbac/members/:id,rbac.controller.ts:75-79) does not delete the user or revoke their tokens — role list in JWT goes stale until next login/refresh (JWT roles embedded at issue time —auth.service.ts:460-476). UI copy: "Role changes apply on next sign-in." org_adminself-demotion/self-removal: allow with confirmation (no server guard) — client warns "you may lose access".
9. Concurrency & multi-device
- Two admins editing the same user: last-write-wins with
versionbump (base.repository.ts:57-66) — no conflict error surfaced; the client refreshes from PATCH response so stale forms are overwritten. - Two admins creating the same email concurrently: both pass the pre-check,
one hits the unique index (
user.schema.ts:83) → 500 duplicate-key (not a clean 409 — OQ-14). Client treats 5xx with "try again"; server fix(planned). - List refresh after any mutation; no optimistic list-row removal except soft-delete (safe, idempotent, 00-shared/06 §3.5).
10. Error-behaviour policy (per 00-shared/06 §5)
| Code | Behaviour |
|---|---|
| 400 | field errors inline; import errors as result rows |
| 401 | silent refresh → session expiry flow |
| 403 | hide action; if reached → 403 screen |
| 404 | "User not found" empty state (never leak existence) |
| 409 | inline conflict + "search existing" affordance |
| 422 | (planned) reserved; not produced by users module today |
| 429 | backoff copy + no auto-retry |
| 5xx | generic + requestId; retry offered; note concurrency dupes (OQ-14) |
10 — Interaction Specification (Users Module)
- 1. Touch gestures (phone/tablet)
- 2. Desktop/web (mouse + keyboard)
- 3. Haptics (mobile)
- 4. Motion language
- 5. Micro-interactions
- 6. Import progress interaction (long-running)
- 7. Frustration controls
Module-specific interactions on top of 00-shared/08_Interaction_&_Motion.md. Tokens
m-*per 00-shared/02 §6; all durations/curves from there.
1. Touch gestures (phone/tablet)
| Gesture | Behaviour | Where |
|---|---|---|
| Tap row | Navigate to detail (phone) / select in pane (≥ 840 dp); ripple m-fast | Users list |
| Long-press row | AppMenu: View · Edit · Deactivate/Activate · Delete · GDPR erase | List rows |
| Swipe left (row) | Reveal ≤ 2 actions: Deactivate, Delete (00-shared/08 §1, keep ≤ 2) | List rows (phone) |
| Pull down | Refresh list (resets to page 1, bypasses cache) | List |
| Tap FAB | New user (scale-in m-entrance; hide on scroll-down) | List |
| Back gesture | Pop; from detail → list (state preserved) | All |
2. Desktop/web (mouse + keyboard)
| Input | Behaviour |
|---|---|
| Hover row | 2% surface darken + tooltip on status badge (m-fast) |
| Right-click row | Same context menu as long-press |
Ctrl+K | Global search (shell-level) |
N | New user (list focused, no input focused) |
I | Import CSV (list focused) |
/ | Focus search field |
Esc | Close dialog/sheet/menu; from search → clear + unfocus |
Enter | Submit focused form; in dialogs = primary action (typed confirms require typing first) |
| Arrow keys | Navigate list rows (desktop master-detail); menus |
Ctrl+Enter | Submit edit form from any text area |
3. Haptics (mobile)
| Event | Haptic |
|---|---|
| Create/update success | lightImpact |
| 409 conflict / import errors shown | mediumImpact |
| Long-press menu | selectionClick |
| Pull-to-refresh complete | lightImpact |
| Delete/erase confirm | heavyImpact |
| Import complete (all rows) | lightImpact; partial success: mediumImpact |
Haptics disabled per system setting; none on desktop (00-shared/08 §3).
4. Motion language
| Interaction | Motion |
|---|---|
| List → detail | Slide forward m-base; hero avatar flight m-slow (00-shared/08 §4) |
| Skeleton → rows | Cross-fade m-slow, no pop |
| Status badge change | Color/size transition m-fast; icon morph AnimatedSwitcher |
| Filter chips toggle | m-fast color transition |
| Import step change | Fade + slight rise m-slow; stepper indicator slide m-base |
| Import progress | Determinate LinearProgressIndicator m-instant value updates (no pulse) |
| Error table expand | AnimatedSize m-base, never layout pop |
| Dialog (delete/erase) | Scale+fade m-base; scrim m-base |
| Snackbar (undo/confirm) | m-base slide |
Reduced motion: all transitions ≤ m-instant fades; no stagger, no hero
(00-shared/09 §8).
5. Micro-interactions
| Interaction | Detail |
|---|---|
| Search typing | Debounce 300 ms; clear (×) button fades in with input |
| Result count | "42 users" live-region update after search completes |
| Status toggle | Switch flips immediately; server round-trip; rollback on error + snackbar |
| FAB | Appears m-entrance scale; hides on scroll-down, shows on scroll-up |
| Import start | Button → spinner ("Importing…"); anti-double-submit (00-shared/08 §6) |
| Import row error click | Expands inline message (already visible) — no modal for single errors |
| Avatar upload | Overlay spinner on avatar; success check morph; revert on failure |
| Infinite scroll | Bottom spinner fade-in m-instant; "end of list" footer |
6. Import progress interaction (long-running)
Since POST /users/import is synchronous (users.service.ts:233-282):
- Phase 1 — parsing (client, local): indeterminate? No — determinate fake-local parse with real row count (isolate reports progress every ~200 rows).
- Phase 2 — upload: determinate byte progress (dio
onSendProgress). - Phase 3 — server processing: indeterminate spinner labeled "Server is importing N rows — this can take a minute".
- Phase 4 — result: determinate summary (imported/failed counts,
m-entranceentrance).
Rule: never display a fake percentage for phase 3 (honest labeling, OQ-6). Cancel button only during phases 1–2 (local); phase 3 cannot be cancelled (server commits row-by-row).
7. Frustration controls
- Every interactive element responds within 100 ms (00-shared/08 §6).
- All network actions show pending state (button spinners, row overlay).
- Double-tap FAB → guarded (already navigating); double-submit guarded.
- Import phase-3 offers "You'll be notified when done"
(planned)— today the request blocks the UI; keep the screen focused and show elapsed time.
11 — Design System Mapping (Users Module)
- 1. Theme & tokens used
- 2. Status colour mapping (semantic, icon+label — 00-shared/09 §9)
- 3. Flutter widget mapping (module-specific)
- 4. Layout tokens
- 5. Iconography
- 6. Typography mapping
- 7. Theming rules (enforced, 00-shared/04 §7)
- 8. Responsive behaviour
- 9. Empty/error/loading tokens
Maps the module's components/screens to the token set (00-shared/02_Design_Tokens.md) and M3 widget mapping (00-shared/04_Design_System_Mapping.md).
1. Theme & tokens used
| Token | Usage in this module |
|---|---|
primary/onPrimary | FAB "Add user", primary CTAs (Start import, Save) |
success (= secondary) | active status badge, import success counts |
warning (= tertiary) | suspended badge accent, partial-import banner |
error | suspended badge variant, delete/erase dialog actions, import error counts |
primaryContainer | invited badge, selected role chips, info banners |
surfaceVariant | filter chips unselected, input fills |
inverseSurface | snackbars |
mono (00-shared/02 §2) | row numbers in import error list, erased-*@anonymized.invalid display, member ids |
r-sm…r-full | badges r-sm, cards/inputs r-md, dialogs r-lg, avatar r-full |
e-0…e-5 | list rows e-0, cards e-1, FAB e-3, dialogs e-4 |
m-* motion | per 10_Interaction_Specification.md §4 |
2. Status colour mapping (semantic, icon+label — 00-shared/09 §9)
Status (user.schema.ts:7-12) | Badge token | Icon | Label |
|---|---|---|---|
active | success container | check | Active |
inactive | outlined neutral | circle-off | Inactive |
suspended | error/tertiary | ban | Suspended |
invited | primaryContainer | Invited |
Never color-only; every badge pairs icon + text (00-shared/09 §9). Dark-mode variants per token table (00-shared/02 §1).
3. Flutter widget mapping (module-specific)
| Module component | Flutter |
|---|---|
UserListTile | ListTile + CircleAvatar (initials) + MenuAnchor |
StatusBadge | custom Container pill (radius r-sm) + Icon + Text |
RoleChips | FilterChip row (Wrap) |
FilterBar | SearchBar + Wrap of chips + ActionChip clear-all |
ImportWizard | custom AppStepper (00-shared/03); step content via AnimatedSwitcher |
CsvPreviewTable | DataTable (desktop) / ListView of ListTiles (phone) |
ImportErrorList | dense ListTile rows, mono row number, Badge count |
PreferencesPanel | SwitchListTile ×3, RadioListTile ×3, DropdownButtonFormField |
AvatarUploader | GestureDetector avatar + showModalBottomSheet (pick/crop) |
TypedConfirmDialog | AlertDialog + TextField + destructive FilledButton |
4. Layout tokens
- List row height ≥ 56 (00-shared/02 §3); dense error rows ≥ 48.
- Screen padding 16/24 (00-shared/04 §6); card gap 12; section gap 24.
- Master-detail breakpoint 840 dp (00-shared/04 §6); filters collapse to a bottom sheet on phone (< 600 dp).
5. Iconography
Material Symbols outlined, 24 dp default (00-shared/02 §7); status icons
filled variant. Icons used: person_add (FAB), upload_file (import),
download (template/errors), edit, block (suspend), delete_forever,
shield_person (erasure), notifications, palette (theme), photo_camera
(avatar). All with labels/tooltips (00-shared/02 §7).
6. Typography mapping
| Element | Token |
|---|---|
| Screen title "Users" | headlineMedium |
| Row title (displayName) | titleMedium |
| Row subtitle (email) | bodyMedium onSurfaceVariant |
| Badge text | labelMedium |
| Import counts | displaySmall (headline numbers) + bodyMedium captions |
| Error row message | bodyMedium error-container tint |
| Row numbers / ids | mono |
7. Theming rules (enforced, 00-shared/04 §7)
- No literal colors — token references only.
- No literal spacing —
AppSpacinggrid. - No literal durations —
AppMotion. - Dark mode first-class (badge contrast ≥ 4.5:1 text, ≥ 3:1 UI 00-shared/09 §2).
- Tenant branding overrides
primaryonly — status colors stay semantic.
8. Responsive behaviour
| Class | Layout |
|---|---|
| Phone < 600 dp | single pane; filters in bottom sheet; FAB |
| Tablet 600–839 dp | list + expanded row info; filter chips inline |
| Landscape/desktop ≥ 840 dp | master-detail; pager; hover states; keyboard shortcuts (10_Interaction_Specification.md §2) |
9. Empty/error/loading tokens
- Skeleton:
surfaceVariantshimmerm-slow(00-shared/03 AppSkeleton). - Empty state:
displaySmalltitle (00-shared/03 AppEmptyState). - Error state:
erroricon,titleLarge, generic copy + requestId (00-shared/03 AppErrorState).
12 — API Mapping (Users Module)
- E1 — Create user
- E2 — List users
- E3 — Get user
- E4 — Update user
- E5 — Delete user (soft)
- E6 — GDPR erasure
- E7 — Get preferences
- E8 — Update preferences
- E9 — Upload avatar
- E10 — Bulk import users (inline, synchronous)
- E11 — Bulk module (adapters) & export
- E12 — RBAC bridge (roles & membership)
- E13 — Auth module touchpoints (self-service context)
- Rate limits (client-relevant, 00-shared/07 §4)
- Client contract summary (all screens)
Exact endpoints per screen. Wire contract per 00-shared/07: base
/api/v1, Bearer JWT, success{success:true,message:"OK",data,meta?,timestamp, requestId}, error envelope with codes. Only shapes in code are used.(planned)/(forward-looking)marked. Sources:users.controller.ts,users.service.ts,bulk.controller.ts,rbac.controller.ts,auth.controller.ts.
E1 — Create user
| Endpoint | POST /api/v1/users (users.controller.ts:36-40) |
| Guard | JwtAuthGuard (:31); RBAC permission guard (planned) — user.create exists (permissions.constants.ts:7) but is not enforced on this route (OQ-11) |
| Request | CreateUserDto (create-user.dto.ts:5-62) — firstName/lastName/email required; status enum default active (:44-47); tenantId from token only |
| Response | 201 envelope, data = user doc (displayName auto "<firstName> <lastName>" — users.service.ts:62); no meta |
| Errors | 400 VALIDATION_ERROR; 409 DUPLICATE_RESOURCE email/phone (users.service.ts:50-60); 429 RATE_LIMITED; 5xx (incl. race dup-key, OQ-14) |
| Side effects | UserCreated event → in-app queue user-created-notification (event-queue-map.ts:10) |
| Client | SS2 create form; roles via E12 after success; cache invalidate users list |
| Realtime | in-app notification to tenant (P9, 02_User_Personas.md) |
E2 — List users
| Endpoint | GET /api/v1/users?page&limit&sort&q (users.controller.ts:42-46) |
| Params | page ≥1 default 1; limit 1–100 default 20; sort (-field desc); q → $or regex on firstName/lastName/email/displayName, case-insensitive (users.service.ts:92-99); default sort -createdAt (:103) |
| Response | paginated: data array + meta {page,limit,totalItems,totalPages,hasNext,hasPrevious} (pagination-query.dto.ts:32-55) |
| Errors | 400 (bad params); 429; 5xx |
| Client | SS1 list; debounced search; infinite scroll while hasNext; pull-to-refresh bypasses cache |
| Filters | No status/role query params (users.service.ts:90-115) — status/role chips are client-side (planned) server params (OQ-9) |
| Cache | client paginated cache sl:{tenant}:users:{query} TTL 5 min (volatile, 00-shared/06 §3.3) |
E3 — Get user
| Endpoint | GET /api/v1/users/:id (users.controller.ts:48-53) |
| Response | 200 envelope, data = user doc |
| Errors | 400 (bad ObjectId); 404 RESOURCE_NOT_FOUND (users.service.ts:86); cross-tenant/deleted ids → 404 (scoped repo — base.repository.ts:20-30; no existence leak) |
| Client | SS3 detail / SS7 self profile (:id = JWT sub, auth.service.ts:461; server self-guard (planned) OQ-3) |
E4 — Update user
| Endpoint | PATCH /api/v1/users/:id (users.controller.ts:55-60) |
| Request | UpdateUserDto (update-user.dto.ts:5-70) — partial; $set merge + version +1 (base.repository.ts:57-66) |
| Response | 200 updated doc; displayName recomputed when names change (users.service.ts:136-139) |
| Errors | 404; 409 email/phone change conflicts (:120-134); 400 enums invalid |
| Side effects | UserUpdated (changes list) → audit-write (users.service.ts:145-153; event-queue-map.ts:11) |
| Client | SS5 edit; SS6 status change; conflict → inline field error |
E5 — Delete user (soft)
| Endpoint | DELETE /api/v1/users/:id (users.controller.ts:62-67) |
| Behaviour | soft delete: isDeleted:true, deletedAt, deletedBy + version (base.repository.ts:68-74); handler returns void → 200 data null; emits UserDeleted → audit-write (users.service.ts:176-183; event-queue-map.ts:12) |
| Errors | 404 (users.service.ts:175) |
| Client | SS8 typed-confirm; row removed; snackbar "purged after 30 days" |
| Purge | TENANT_PURGE worker hard-deletes isDeleted docs older than 30 days (tenant-purge.worker.ts:32-43); idempotent |
| Sessions | active JWTs not revoked (OQ-8); user cannot re-login (users.repository.ts:21-25) |
E6 — GDPR erasure
| Endpoint | POST /api/v1/users/:id/erasure (users.controller.ts:69-76) |
| Behaviour | anonymize (Erased User, erased-<id>@anonymized.invalid), isDeleted:true (users.service.ts:187-197); enqueue gdpr-erasure on tenant-purge queue, attempts:3, exponential backoff 5000 ms (:200-209); worker hard-deletes user doc (tenant-purge.worker.ts:49-56) |
| Errors | 404 (users.service.ts:198) |
| Client | SS8 typed "ERASE" confirm; irreversible copy |
E7 — Get preferences
| Endpoint | GET /api/v1/users/:id/preferences (users.controller.ts:78-83) |
| Response | data = user.preferences ?? {} (users.service.ts:168-171) — never null |
| Errors | 404 (via findById) |
| Client | SS6 load |
E8 — Update preferences
| Endpoint | PATCH /api/v1/users/:id/preferences (users.controller.ts:85-93) |
| Request | UpdateUserPreferencesDto (update-user-preferences.dto.ts:4-21) — notifications{email,push,sms}, `theme{mode: light |
| Behaviour | full replace $set:{preferences:dto} (users.service.ts:161-163) — client sends merged complete object |
| Response | 200 updated doc |
| Errors | 404; 400 (non-object groups) |
| Client | SS6; switches optimistic but submit full object |
E9 — Upload avatar
| Endpoint | POST /api/v1/users/:id/avatar (users.controller.ts:95-102) — multipart, field file (FileInterceptor('file') :96) |
| Request | image buffer; server prefixes filename ${id}- (users.service.ts:219); no size/mime validation in code (OQ-10) |
| Response | data {avatarFileId} (:230); previous avatar deleted best-effort (:227-229) |
| Errors | 404 (:216); 400 missing file (multer); 5xx storage failures |
| Client | SS7 AvatarUploader; display URL resolution (planned) (endpoint returns id only) |
E10 — Bulk import users (inline, synchronous)
| Endpoint | POST /api/v1/users/import (users.controller.ts:104-110) — multipart, field file |
| Behaviour | sync parse + per-row create (users.service.ts:233-282); header required + ≥1 row (:238-243); headers lowercased (:244-247); required email (:256-259); dup email → row error (:260-263); defaults Unknown/en/UTC (:265-271); no UserCreated events for rows (:264-272 direct repo.create) |
| Response | 200 envelope, data = {imported: number, errors: string[]} (:281) — errors "Row N: message" |
| Errors | 429; 5xx. Malformed/empty CSV is not an error envelope — returns {imported:0, errors:[…]} (:238-243) |
| Async/polling | none exists — result returned in the same request. Async queue + polling (planned) per PLAN.md 2.7 (OQ-6) |
| Client | SS4 wizard; progress UI per 10_Interaction_Specification.md §6 |
E11 — Bulk module (adapters) & export
| Import | POST /api/v1/bulk/import/:entity (bulk.controller.ts:35-48) — multipart file required (:43-46); parser csv-parse/sync (bulk-import.service.ts:26-30); malformed → 400 VALIDATION_ERROR (:31-33); report {entity,totalRows,imported,failed,errors[{rowNumber,errors[]}]} (import-adapter.interface.ts:14-25); rowNumber = physical line (:46) |
| Entities | students only (students-import.adapter.ts:15) — creates users via UsersService.create per row (:66-71); users entity → 404 RESOURCE_NOT_FOUND "No import adapter" (bulk-import.service.ts:17-20) (planned) users adapter |
| Export | GET /api/v1/bulk/export/:entity (bulk.controller.ts:50-60) — text/csv, Content-Disposition: attachment; filename="<entity>.csv" (:55-58); students rows = admissionNumber/rollNumber/status/admissionDate (students-import.adapter.ts:87-98) |
| Client | SS4 alternate path when users adapter lands; template download |
E12 — RBAC bridge (roles & membership)
| Roles | GET /api/v1/rbac/roles (rbac.controller.ts:27-31) — role slugs for chips/selectors |
| Members | GET /api/v1/rbac/members (:57-61) — join for role chips; POST /api/v1/rbac/members {userId, roles[]} (:63-67; creates ACTIVE member — rbac.service.ts:113-127); PATCH /api/v1/rbac/members/:id roles (:69-73); DELETE /api/v1/rbac/members/:id (:75-79) |
| Guard | org_admin role required (rbac.controller.ts:21-22) — members API unavailable to HR-only roles (OQ-15) |
| Cache | permissions cached Redis sl:{tenantId}:perm:{userId} TTL 300 s (rbac.service.ts:44-73) |
E13 — Auth module touchpoints (self-service context)
| Register | POST /api/v1/auth/register (rate 5/min — auth.controller.ts:30-36): creates user + auth_account + org_admin membership + welcome email (auth.service.ts:54-121; event-queue-map.ts:7) |
| Verify/resend | POST /auth/verify-email (:74-81); POST /auth/resend-verification (5/120 s, :83-89) — verification email is the only email an admin-created user can get today (via manual resend, needs auth) — invite email (planned) |
| Sessions | GET /auth/sessions, DELETE /auth/sessions/:id, POST /auth/logout-all (:66-72,130-142) — logout-all is the only session revocation tool (not called on delete/erase — OQ-8) |
Rate limits (client-relevant, 00-shared/07 §4)
| Tier | Limit | Notes |
|---|---|---|
| auth | 10/min (5 register, 10 login, 20 refresh, 5 resend/120 s) | countdown copy, no auto-retry |
| api | 100/min | users CRUD/import default tier |
| admin | 500/min | (planned) if admin endpoints get throttled |
Client contract summary (all screens)
| Concern | Rule |
|---|---|
| Auth | Bearer JWT; 401 → single-flight refresh → replay; fail → session expiry (00-shared/06 §3.6) |
| Optimistic | only safe toggles (status, preferences switches) with rollback; create/import/erase never optimistic |
| Idempotency | DELETE/PATCH retry-safe; Idempotency-Key support unconfirmed (B6) — import retry safe by dedup (users.service.ts:260-263) |
| Offline | reads from last-good cache + banner; writes blocked (no module offline queue) |
| Pagination | page/limit/sort/q + meta exact (00-shared/07 §5) |
| Realtime | WS topics n/a for users today; (forward-looking) user.updated, user.deleted |
| Error mapping | 00-shared/06 §5: 400 field, 403 hide/deny, 404 empty, 409 inline conflict, 429 backoff, 5xx generic + requestId |
13 — State Management (Users Module)
- 1. Cubit map
- 2. UsersListCubit
- 3. UserDetailCubit
- 4. UserFormCubit (create + edit)
- 5. PreferencesCubit
- 6. BulkImportCubit — the import state machine
- 7. SelfProfileCubit
- 8. MembershipCubit
- 9. Cross-cutting
Per-screen state on top of 00-shared/06_State_Management.md (Bloc/Cubit proposal). Cubits are
(planned); repository/API facts are exact. Special attention: the long-running import state machine (06 §6).
1. Cubit map
UsersListCubit → SS1
UserDetailCubit → SS3
UserFormCubit → SS2/SS5
PreferencesCubit → SS6
SelfProfileCubit → SS7
BulkImportCubit → SS4 (owns the import state machine, §6)
MembershipCubit → SS3 Membership tab (roles)
SessionCubit → shared (self `:id`, permissions)
2. UsersListCubit
- State:
LoadState(00-shared/06 §3.1) +items,page,limit=20,sort=-createdAt(default,users.service.ts:103),q,totalItems,hasNext, plusrolesMap(fromGET /rbac/membersjoined by userId —rbac.controller.ts:57-61). - Events:
Load,Refresh(reset page 1, bypass cache),LoadMore,ChangeQuery(q)(debounced 300 ms, resets page),ChangeStatusFilter,ChangeRoleFilter,Retry. - Server contract: envelope
data[]+meta {page,limit,totalItems, totalPages,hasNext,hasPrevious}(pagination-query.dto.ts:32-55). - Status/role chips filter loaded pages only (no server params —
users.service.ts:90-115; OQ-9) — state carries aclientFilterActiveflag for the honest caption. - Cache:
sl:{tenant}:users:{query}TTL 5 min;Refreshbypasses. - Row action outcomes: after
DELETE /users/:id(soft) remove row locally + snackbar (safe, idempotent); afterPATCH statusupdate badge locally, rollback on error.
3. UserDetailCubit
- State:
LoadState+user,member?(fromGET /rbac/membersjoin),preferences(lazy via tab). - 404 handling:
RESOURCE_NOT_FOUND→ empty-state "User not found" (cross-tenant/deleted/erased —base.repository.ts:20-30). - After Edit save: replace
userwith PATCH response (server recomputeddisplayName—users.service.ts:136-139). - Erasure: sets flag
erased→ navigates back, list refresh.
4. UserFormCubit (create + edit)
- Shared by SS2/SS5; mode
create | edit. - Field-level state mirrors
CreateUserDto/UpdateUserDto(08_Form_Specifications.md §1-§2); dirty tracking for unsaved-changes dialog. - Submit flow (create):
POST /users→ on success, if roles selected →POST /rbac/members; second call failure → statecreatedWithoutRoles→ persistent retry banner (membership unique index makes retry safe —organization-member.schema.ts:48). - Errors: 400
VALIDATION_ERROR.detailsmapped per field (00-shared/07 §3); 409 email/phone → inline conflict state + "search existing" affordance; 429 → backoff copy; 5xx → generic + requestId. - 5xx on create may be a concurrent duplicate (unique index race —
user.schema.ts:83; OQ-14) — copy "someone may already exist with this email" + refresh list.
5. PreferencesCubit
- State:
LoadState+ fullpreferencesobject (GET→?? {},users.service.ts:168-171). - Optimistic toggles per
00-shared/06 §3.5(safe switches), but submit sends the complete merged object — full-replace contract (users.service.ts:161-163); rollback on failure. - Debounced auto-save
(proposed)— every toggle change PATCHes the full object; avoid race by serializing saves (queue one in-flight save).
6. BulkImportCubit — the import state machine
Synchronous backend (users.service.ts:233-282) ⇒ the machine is
client-staged, with honest labels (never fake server progress — OQ-6):
sealed class ImportState {
Idle
ParsingLocal // isolate parse: rows parsed so far / total (real)
PreviewReady // headerMap, rows[], warnings[]
Uploading // bytes sent / total (real, dio onSendProgress)
ServerProcessing // indeterminate: "Server is importing N rows"
Succeeded // imported, errors[]
Failed // ApiException (network/429/5xx), retryable
}
| Event | Transition | Notes |
|---|---|---|
PickFile | Idle → ParsingLocal | reject non-CSV/size > 2 MB (OQ-10) |
ParseProgress(n,total) | ParsingLocal (re-emit) | isolate posts every ~200 rows |
ParseDone | → PreviewReady | warnings: missing optional cols, quotes (naive parser — users.service.ts:251; OQ-7), in-file dup emails (:260-263), Unknown fallback preview (:265-266) |
ConfirmImport | → Uploading | multipart field file (users.controller.ts:104-110) |
UploadDone | → ServerProcessing | request in flight; elapsed timer |
ImportDone(payload) | → Succeeded | {imported, errors} (users.service.ts:281) |
ImportError(e) | → Failed | retry allowed — server dedups emails (:260-263), retry never duplicates |
Cancel | Uploading → Idle | local only; ServerProcessing cannot cancel |
ImportMore | Succeeded/Failed → Idle | keep parsed template |
- State survival: cubit lives above the route (registered at shell
scope) so leaving the wizard preserves preview/result; navigation back
reuses state (
04_IA §7). - Result view derives:
errors.isEmpty → success; else counts +ImportErrorListrows; "Download errors" client-side CSV export. - Concurrency: one import at a time per tenant UI; block the FAB/route
guard while
Uploading/ServerProcessing(anti-double-submit,00-shared/08 §6). Two tabs importing the same file: server dedups; both see consistenterrors(each row's email check is sequential —users.service.ts:250-279). - Idempotency key:
Idempotency-Keysupport unconfirmed (B6); safe because duplicates are rejected not re-created.
7. SelfProfileCubit
- Resolves
:idfrom JWTsub(auth.service.ts:461); stateLoadState- user; avatar upload sub-state (uploading → success
{avatarFileId}/ error) —users.service.ts:230.
- user; avatar upload sub-state (uploading → success
- Permission-derived visibility: edit/delete/erase controls rendered via
PermissionScoped(07 §11) with the client permission set (00-shared/05 §9).
8. MembershipCubit
- Loads
GET /rbac/members+GET /rbac/roles(rbac.controller.ts:27-31,57-61); role edit →PATCH /rbac/members/:id(:69-73); remove →DELETE /rbac/members/:id(:75-79). - Not org_admin (403) → empty state with "no role visibility" copy (OQ-15).
- Copy rule: "Role changes apply on next sign-in" (JWT embeds roles at issue
—
auth.service.ts:460-476).
9. Cross-cutting
- Auth state (
00-shared/06 §3.6): any 401 → single-flight refresh; on failure, session-expiry overlay preserving list query. - Connectivity (
00-shared/06 §3.7): offline → cached list + banner; writes blocked (no module offline queue); import wizard blocked at step 1 with guidance. - Permission changes → route rebuild (
00-shared/05 §9): losinguser.*mid-session removes users routes. - Analytics
(proposed)(10_QA_Baseline.md §8):users.list.search,users.create.submit,users.import.start|complete|failure,users.erasure.confirm,users.status.change.
14 — QA Checklist (Users Module)
- 1. Functional (core CRUD)
- 2. GDPR erasure
- 3. Bulk import — users inline endpoint
- 4. Multi-tenant isolation
- 5. Avatar
- 6. Permissions & guards
- 7. Performance & reliability
- 8. UX & a11y (per 00-shared/09)
- 9. Analytics (proposed, 00-shared/10 §8)
- 10. Release gates
Module-specific checks on top of 00-shared/10_QA_Baseline.md. Every item is verifiable against the backend behaviors cited. Backend facts exact; expected-client column is this module's contract.
1. Functional (core CRUD)
-
Create user happy path → 201 doc;
displayName="<first> <last>"(users.service.ts:62); list shows row after refresh. -
Create without
displayNamevs with explicitdisplayName— explicit preserved. -
Duplicate email → 409 with message text (
users.service.ts:50-54); duplicate phone → 409 (:56-60). -
Email stored lowercase (
user.schema.ts:28-29) —JOHN@X.comdisplays lowercase. -
PATCH partial: only changed fields;
versionincrements (base.repository.ts:57-66). -
PATCH email to another user's email → 409 (
users.service.ts:120-126); same email (no-op) passes (:120guard). -
Rename first/last →
displayNamerecomputed (:136-139). -
GET list:
qmatches firstName/lastName/email/displayName case-insensitive (:92-99); default sort newest-first (:103). -
Pagination envelope exact:
page/limit/totalItems/totalPages/hasNext/hasPrevious(pagination-query.dto.ts:32-55);limit=101→ 400 (Max 100:18);page=0→ 400 (Min 1:10). - DELETE → row disappears from list & GET :id → 404; re-create same email allowed (no tombstone).
-
Deleted user cannot log in (
users.repository.ts:21-25). -
Status PATCH: all 4 enum values round-trip (
user.schema.ts:7-12); invalid value → 400.
2. GDPR erasure
-
POST /users/:id/erasure→ doc anonymized:Erased User/erased-<id>@anonymized.invalid,isDeleted:true(users.service.ts:187-197). -
Purge job enqueued on
tenant-purgequeue withattempts:3+ exponential backoff 5000 (:200-209) — verify queue contents after call. -
Worker hard-deletes user doc (
tenant-purge.worker.ts:49-56); job idempotent on replay (:26). - Erased user 404s everywhere; erasure twice → 404 (not crash).
3. Bulk import — users inline endpoint
C1 — 1,000-row scenario
-
Upload 1,000-row CSV (valid) → 201
{imported:1000, errors:[]}; no timeout at 120 s client budget (00-shared/11 §5); measure server time — sequential per-row DB writes (users.service.ts:250-279) at ~1,000 round-trips; if > 2 min → flag to product (async(planned),PLAN.md 2.7). - Memory: file ≤ ~1 MB parsed in-memory; no client OOM in preview isolate.
- Progress UI honest: phases 1–2 real progress, phase 3 indeterminate (10_Interaction_Specification.md §6).
C2 — Concurrency & idempotency
-
Same file uploaded twice → second run:
imported:0, all rows "already exists" (users.service.ts:260-263) — no duplicates created (sequential check+insert + unique indexuser.schema.ts:83). -
Two parallel requests with same email → one succeeds; other: either 409-style row error (if it saw the row) or 500 dup-key (race — OQ-14). Record result; server fix
(planned)to map to 409. - Retry after network loss mid-request → no partial duplicates (each email checked before insert).
C3 — CSV edge cases
-
Empty file / header only →
{imported:0, errors:["CSV must have a header row and at least one data row"]}(users.service.ts:238-243) — not an error envelope. -
Header casing/whitespace:
Email, FirstName→ normalized (:244-247). -
first_name/last_namealiases work (:265-266); missing names →Unknown(:265-266). -
Missing email →
Row N: missing email(:256-259); row number = physical line (client preview must agree). -
In-file duplicate emails → row 2+ flagged (
:260-263). -
Quoted commas
"Doe, Jr."→ mis-split (naive parser:251); verify the client preview warns before upload (OQ-7); document limitation. -
BOM/UTF-8:
file.buffer.toString('utf-8')(:236) — BOM in first header cell; client strips BOM in preview and warns if server mis-parses. -
CRLF line endings handled (
split('\n')+ trim filter:237). - 100+ error rows → UI paginates error list; "Download errors" CSV matches row numbers.
C4 — Bulk module (students adapter)
-
POST /bulk/import/students: valid file → report{entity,totalRows,imported,failed,errors[{rowNumber,errors[]}]}(import-adapter.interface.ts:14-25); rowNumber =index+2(bulk-import.service.ts:46). -
Missing required column → row error (not crash) (
students-import.adapter.ts:41-45). -
Unknown grade/section/academicYear → per-row ref errors (
:113-125); no class for grade+section → error (:138-141). -
Duplicate admissionNumber / existing email → row errors (
:49-56). -
Malformed CSV → 400
VALIDATION_ERROR(bulk-import.service.ts:31-33). -
entity=users→ 404 "No import adapter" (bulk-import.service.ts:17-20) — UI must not offer it ((planned)users adapter). -
GET /bulk/export/students→text/csv+Content-Disposition: attachment(bulk.controller.ts:50-60).
4. Multi-tenant isolation
-
Tenant A user list never shows tenant B rows (
base.repository.ts:20-30). -
Tenant A
GET /users/:idwith tenant B id → 404 (no leak, 00-shared/07 §3). -
Same email in two tenants → both created (unique is per-tenant —
user.schema.ts:83). -
Platform admin bypass scopes (
base.repository.ts:21-23) — verify token flag only for platform surfaces.
5. Avatar
-
Upload → 200
{avatarFileId};filenameprefixed${id}-(users.service.ts:219); docavatarFileIdupdated (:223-225). -
Re-upload replaces: previous file deleted best-effort (
:227-229). - Missing file / wrong field name → multer 400.
- Oversized image: no server cap (OQ-10) — client enforces ≤ 2 MB; verify graceful client error.
6. Permissions & guards
-
Unauthenticated → 401 on all users routes (
JwtAuthGuard—users.controller.ts:31). -
Known gap: no RBAC permission enforcement on users routes today (OQ-11) — client hides actions via
PermissionScoped; re-test the moment server guards land. -
rbacroutes requireorg_admin(rbac.controller.ts:21-22): HR-only user → 403 on members API → role chips show "—" (OQ-15). -
Cross-tenant JWT → 403/404 per
PLAN.md 1.6(00-shared/07 §6).
7. Performance & reliability
- List load (network) ≤ 2 s at 10k users (00-shared/10 §1); scroll 60 fps with avatars.
-
Indexes present:
(tenantId,email)unique,(tenantId,phone)partial unique,(tenantId,status),(tenantId,displayName)(user.schema.ts:83-92). - No duplicate parallel requests for same query (00-shared/10 §1).
- Preferences save serialized (one in-flight PATCH).
8. UX & a11y (per 00-shared/09)
- Status never color-only (icon+label) — 11 §2.
-
Typed confirms:
deleteandERASEwork; wrong text disables button; barrier tap cancels. - TalkBack/VoiceOver: list rows announce name+status; live regions for search count and import progress; keyboard-only flow (list → filters → dialogs).
- Text scale 2×: no clipping in list rows, error table, dialogs.
- Reduced motion: no hero/stagger in wizard transitions.
- Dark mode full pass (badges, error table, dialog contrast ≥ 4.5:1 text).
- Offline: cached list + banner; import blocked with guidance; write attempts show guidance (00-shared/10 §2).
9. Analytics (proposed, 00-shared/10 §8)
-
Events fire:
users.list.search,users.create.submit|success|failure(409),users.import.start|complete|failure,users.status.change,users.erasure.confirm,users.avatar.upload.
10. Release gates
- Typecheck/lint/tests green (backend unchanged by this doc).
-
Backend behavioral probes above automated where possible (unit:
users.service.spec, bulkbulk-import.service.spec.tsexists — extend for import edge cases). - Perf budgets, a11y checklist, offline matrix per 00-shared/10.
15 — Flutter Implementation Guide (Users Module)
- 1. Module structure
- 2. UsersRepository
- 3. CSV parse in isolate (preview + validation)
- 4. Progress UI (long-running import)
- 5. Import wizard UI
- 6. Key screens & widgets
- 7. Future hooks (planned/forward-looking)
- 8. Tests
Module implementation on top of 00-shared/11_Flutter_App_Architecture.md. Forward-looking (no client repo). Backend constraints cited; CSV parse and progress UI get the deep treatment as required.
1. Module structure
lib/features/users/
├── data/
│ ├── dto/user_dto.dart # envelope data payload → User
│ ├── dto/import_result_dto.dart # {imported, errors[]}
│ ├── dto/import_report_dto.dart # bulk module {entity,totalRows,imported,failed,errors[]}
│ ├── models/user.dart # normalized (status enum, DateTime, avatarFileId?)
│ ├── models/csv_preview.dart # headers, rows, warnings
│ └── repositories/
│ ├── users_repository.dart # CRUD + preferences + avatar
│ └── import_repository.dart # /users/import + /bulk/import/:entity
├── domain/
│ ├── csv_service.dart # isolate-based parse/validate (CSV parse in isolate)
│ └── csv_error_exporter.dart # error CSV download
└── presentation/
├── cubit/ (users_list, user_detail, user_form, preferences,
│ bulk_import, membership, self_profile)
├── pages/ (users_list_page, user_detail_page, user_form_page,
│ import_wizard_page, preferences_page, self_profile_page)
└── widgets/ (user_list_tile, status_badge, role_chips, avatar_uploader,
import_stepper, csv_preview_table, import_error_list)
DTO→model mapping per 00-shared/11 §4 (json_serializable); enums mapped
from UserStatus strings (user.schema.ts:7-12), unknown → fallback
unknown (forward-compat, 07 §3).
2. UsersRepository
list({page, limit, sort='-createdAt', q})→PagedResult<User>from envelopedata+meta(pagination-query.dto.ts:32-55).create(dto),update(id, dto),get(id),remove(id),erase(id),getPreferences(id),updatePreferences(id, full),uploadAvatar(id, File)— multipart fieldfile(users.controller.ts:96), timeout 120 s (00-shared/11 §5).importUsers(File, {onSendProgress})→ImportResult.- Preferences: repository exposes
updatePreferences(id, fullObject)— callers always pass the complete merged object (full-replace contract,users.service.ts:161-163); repository asserts both groups present in dev. - Cache (
00-shared/06 §3.3):sl:{tenant}:users:{query}TTL 5 min via Hive/prefs;refresh=truebypasses. - Typed errors:
ApiException(code, status, fieldDetails)per00-shared/06 §5; 409 exposesmessagefor inline conflict.
3. CSV parse in isolate (preview + validation)
The server's inline parser is naive (split(',') — users.service.ts:251);
the client parser is strict superset (handles quotes) so the preview is
more accurate than the server — warnings surface the mismatch.
// domain/csv_service.dart
class CsvPreview {
final List<String> headers; // lowercased, trimmed (mirror :244-247)
final List<CsvRow> rows; // physical line number included
final List<CsvWarning> warnings; // quotes, dup emails, missing optional cols
}
Future<CsvPreview> parseCsv(File file, {void Function(int, int)? onProgress}) {
// Run in isolate: compute() with a SendPort for progress every ~200 rows.
// NEVER parse on the UI thread — 1000 rows × split is fine, but quoting
// scan + preview table must not jank; 00-shared/11 §13 mandates isolate.
return Isolate.run(() => _parseSync(file, onProgress));
}
- Parse semantics mirror server: first non-empty line = header, lowercased,
trimmed (
users.service.ts:244-247); rows = non-empty lines (:237); required columnemail(:256-259); name aliasesfirstname|first_name,lastname|last_name(:265-266); defaultslanguage=en,timezone=UTC(:270-271); fallback namesUnknown(:265-266). - Warnings (preview must show, not just parse):
- quoted fields present (server will mis-split — OQ-7);
- BOM in first header cell;
- in-file duplicate emails (server rejects 2nd occurrence —
:260-263); - rows that will import as
Unknownname; - header count mismatch with template.
- Row numbers displayed = physical line (must match server's
Row N—users.service.ts:257; precedentbulk-import.service.ts:46). - Isolate pattern:
Isolate.run(() => ...)for one-shot parse (Dart 3); progress viaonProgresscallback only supported with explicitIsolate.spawn+ ports — chooseIsolate.spawnwhen progress UI needs updates;Isolate.runwhen files are small.
4. Progress UI (long-running import)
BulkImportCubit machine (13 §6) renders:
| Phase | Widget | Value source |
|---|---|---|
| ParsingLocal | LinearProgressIndicator(value: parsed/total) | isolate progress (real) |
| Uploading | LinearProgressIndicator(value: sent/total) | dio onSendProgress (real) |
| ServerProcessing | indeterminate bar + elapsed timer | none — honest "Server is importing N rows" (OQ-6) |
| Succeeded | result summary: imported / errors.length (users.service.ts:281) | response |
| Failed | AppErrorState + Retry | ApiException |
- Upload:
dio.FormDatawithMultipartFile.fromFile(..., field: 'file')(users.controller.ts:104-110); timeout 120 s (00-shared/11 §5). - No polling exists (synchronous endpoint) — do not build a poll loop;
when async import lands
(planned)(PLAN.md 2.7), swapServerProcessingfor poll-until-done (see §7). - Retry: safe — server dedups emails (
users.service.ts:260-263); copy: "Already-imported rows will not be duplicated."
5. Import wizard UI
AppStepper(00-shared/03): 1 Upload → 2 Preview → 3 Result.- Step 2
CsvPreviewTable: header map chips, row count, first 5 rows, warning banners (AppBannerseverity warning), template download (email,firstname,lastname,phone,gender,language,timezone). - Step 3
ImportErrorList: mono row numbers + verbatim messages; error CSV export (row,message). - File picking:
file_picker(mimetext/csv); desktop drag-drop viadesktop_dropor file_picker's web support; size guard ≤ 2 MB (OQ-10).
6. Key screens & widgets
- UsersListPage:
RefreshIndicator+ListView.builder(required,00-shared/11 §13);ScrollControllerload-more onhasNext; search viaAppSearchBardebounce 300 ms; filter chips (client-side, labeled(planned)); master-detail ≥ 840 dp viaLayoutBuilder. - UserFormPage:
Form+TextFormFields mirroringCreateUserDto/UpdateUserDto(08 §1-§2); autofill hints;AppDatePickerfor DOB; statusAppDropdown; rolesRoleChips(step 2 of create); submit sequence user→membership withcreatedWithoutRolesretry state. - UserDetailPage: header +
TabBar(Profile/Membership/Preferences); tabs via keep-aliveTabBarView. - SelfProfilePage:
:idfromSessionCubit.sub(auth.service.ts:461); avatar viaAvatarUploader(crop dialog, thenuploadAvatar). - PreferencesPage:
PreferencesPanel; serialize saves.
7. Future hooks (planned/forward-looking)
- Async import:
ImportJobCubitpollingGETjob-status(planned)— wire today'sBulkImportCubit.ServerProcessingto it without UI churn. - Invite flow:
status=invited+ accept endpoint(planned)— badge already renders (07 §3). - Bulk users adapter:
POST /bulk/import/users(planned)— swapImportRepositorystrategy; report UI supports{rowNumber, errors[]}shapes already. - Avatars via storage URL: resolve
avatarFileId→ URL(planned)(E9 returns id only).
8. Tests
- Unit:
csv_service(quotes, BOM, aliases, dup detection, physical line numbers);bulk_import_cubitstate machine (every transition incl. cancel/retry);users_repositoryenvelope mapping (paginated meta); formatters (status labels). - Widget: 3 states per screen (
00-shared/10 §9); golden:StatusBadge×4,ImportErrorList,UserListTile,PreferencesPanel(light/dark, 3 sizes). - Integration: import journey (pick → preview → import → result) against a mocked server; error-CSV download.
- Golden fonts bundled as test assets (
00-shared/11 §12).
01 - Product Overview (Webhooks Module)
StudyLyon - multi-tenant ERP / School Management API. This package designs the Webhooks module client (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, domain events, permissions and wire contracts are derived directly from
src/modules/webhooks/**,src/infrastructure/bullmq/queue.constants.ts,src/infrastructure/bullmq/event-queue-map.ts,src/modules/rbac/permissions.constants.ts,docs/IMPLEMENTATION_PLAN.mdandstudylyon-blueprint/01-Product/PRODUCT_REQUIREMENTS_DOCUMENT.md. No feature is invented - anything not present in source is flagged(planned)/(proposed)/(forward-looking).
Heads-up: per the PRD, the mobile client is out of Phase 1 scope
(PRODUCT_REQUIREMENTS_DOCUMENT.md:144 - "Native mobile apps (web-first)");
this package is the forward-looking spec the client will be built against later.
Webhook management itself is a developer/admin console concern; it will surface in
a web-first admin surface before any mobile surface.
1. Purpose
The webhooks module lets a tenant subscribe to StudyLyon domain events and have them POSTed to an external HTTPS endpoint in near-real-time:
- Subscriptions - named webhooks with a destination
url, a free-formeventslist, a sharedsecretfor HMAC signing, and anenabledflag (webhook.schema.ts:8-30). - Delivery pipeline - every emitted domain event is matched against active
subscriptions and enqueued as a BullMQ job on the
webhook-deliverqueue; a dedicated worker POSTs the payload with an HMAC-SHA256 signature header and records a per-delivery log (webhooks.service.ts:26-86,webhook-delivery.worker.ts:20-105). - Observability - per-webhook delivery logs (last 50), metrics
(total/success/failed/pending), manual retry of the latest failed delivery, and a
test ping (
webhooks.controller.ts:52-76).
| Responsibility | Source |
|---|---|
| Webhook CRUD + soft delete | webhooks.controller.ts:21-50, webhooks.service.ts:31-53 |
| Event fan-out to subscriptions | webhooks.service.ts:26-28, 55-86, webhook.repository.ts:17-29 |
Queue registration (webhook-deliver) | queue.constants.ts:14, webhooks.module.ts:22 |
| Delivery worker (fetch, HMAC, timeout, retry) | webhook-delivery.worker.ts:20-105 |
| Delivery log + metrics + retry + test | webhooks.service.ts:88-161 |
| RBAC permissions | permissions.constants.ts:89-92 |
2. Business goals
| Goal | Measure |
|---|---|
| External systems notified of tenant events | active webhooks matched per event type (webhook.repository.ts:17-29) |
| Authenticated delivery | HMAC-SHA256 signature of the raw body (webhook-delivery.worker.ts:70-82, 103-105) |
| No silent losses | BullMQ retries (3 attempts, exponential backoff 5 s) + delivery log per attempt (webhooks.service.ts:80-83) |
| Tenant isolation | every query tenant-scoped via BaseRepository.scopedFilter (base.repository.ts:20-30) |
| Operational transparency | delivery logs, metrics endpoint, manual retry (webhooks.service.ts:88-161) |
3. User goals
- Tenant developer / system integrator: register a callback URL, choose which events to receive, manage the signing secret, watch delivery health, retry failures, verify signatures.
- School admin: (forward-looking) enable vendor integrations (SMS, attendance
hardware, analytics) that consume StudyLyon events - configured by an
administrator with
webhook.*permissions.
4. Scope
4.1 In scope (implemented backend)
Webhook CRUD with webhook.create/read/update/delete permissions
(permissions.constants.ts:89-92); event fan-out from the in-process EventBus
(event-bus.service.ts:11-22, webhooks.service.ts:26-28); asynchronous delivery
via the webhook-deliver BullMQ queue (queue.constants.ts:14) with 3 attempts /
exponential 5 s backoff (webhooks.service.ts:80-83); HMAC-SHA256 signature header
(webhook-delivery.worker.ts:103-105); per-delivery logs with status
pending/success/failed (webhook-delivery-log.schema.ts:18-23); pause/resume,
manual retry of latest failure, test ping (WebhookTested), and metrics
(webhooks.controller.ts:58-90).
4.2 Planned (IMPLEMENTATION_PLAN.md:48)
Unauthenticated public API scope (30 req/min, 1 min window) described as
"(webhooks, health)" - an inbound webhook receiver for external providers
(test-series integration, IMPLEMENTATION_PLAN.md:856) is planned but not
implemented. No such controller exists in src/ today - marked (planned)
throughout this package.
4.3 Forward-looking (client roadmap)
Admin console UI for webhook management, delivery-log browser with retry, and
event picker; per PRD the mobile client itself is post-Phase 1
(PRODUCT_REQUIREMENTS_DOCUMENT.md:144). Delivery-log pagination and secret
rotation UX fall here too (no endpoint today) - (forward-looking).
4.4 Proposed (analytics)
Analytics events on screens (webhooks.*.*) per 00-shared/10 §8 - (proposed).
5. Non-goals (this version)
- Inbound webhook receiver (receiving from external systems) -
(planned)only, perIMPLEMENTATION_PLAN.md:48. - Event whitelist validation -
eventsis a free-form string array (create-webhook.dto.ts:20-24); any eventType string is accepted. - Signature timestamp / replay protection - signature covers the body only
(
webhook-delivery.worker.ts:103-105); noX-Webhook-Timestampheader. - Attempt bookkeeping -
attemptCountdefaults to 0 and is never incremented (webhook-delivery-log.schema.ts:31-32);lastTriggeredAt/failureCounton the webhook doc are never written (webhook.schema.ts:25-29) - flagged in 14_QA_Checklist.md. - Log pagination / filtering - logs endpoint hard-limits to 50, newest first
(
webhooks.service.ts:88-93). - Secret masking - the signing secret is returned in plaintext by CRUD
responses (documents returned verbatim,
webhooks.service.ts:31-47).
02 - User Personas (Webhooks Module)
- 1. Tenant Developer — "Nadia" (primary)
- 2. School Admin / IT Coordinator — "Mr. Adeyemi"
- 3. Platform / Security Reviewer — "Dr. Okafor"
(forward-looking) - 4. Vendor DevOps — "Anjali"
(forward-looking) - Persona - screen mapping
- Notes
Personas that drive the Webhooks module surfaces. The module is a developer/integration tool: its primary users are technical. Derived from
src/modules/webhooks/**capabilities; nothing outside source is asserted.
1. Tenant Developer — "Nadia" (primary)
| Attribute | Detail |
|---|---|
| Role | Software developer / system integrator at the school (or vendor integrator on behalf of the tenant) |
| Context | Builds or maintains external systems (attendance hardware, SMS gateway, analytics, BI) that must react to StudyLyon events |
| Tools | API docs, Swagger (/api/v1/webhooks), webhook signing library, delivery logs |
| Permissions | webhook.create, webhook.read, webhook.update, webhook.delete (permissions.constants.ts:89-92) |
| Goals | Register an endpoint once; subscribe to exactly the events that matter; verify signatures (X-Webhook-Signature, HMAC-SHA256, webhook-delivery.worker.ts:70-82, 103-105); confirm deliveries land; retry failures |
| Frictions today | No event-type whitelist - free-form events: string[] (create-webhook.dto.ts:20-24); secret returned in plaintext by every CRUD read (webhooks.service.ts:31-47); logs capped at 50, unpaginated (webhooks.service.ts:88-93) |
| Success metric | Endpoint receives events reliably; signature verifies first time; delivery log shows green |
2. School Admin / IT Coordinator — "Mr. Adeyemi"
| Attribute | Detail |
|---|---|
| Role | Non-technical school administrator with elevated RBAC role |
| Context | Approves and operates vendor integrations that consume StudyLyon events |
| Permissions | webhook.read, webhook.update (pause/resume) |
| Goals | See which integrations are active; pause a webhook when a vendor behaves badly (POST /webhooks/:id/pause, webhooks.controller.ts:78-83); view delivery health without reading raw JSON |
| Friction today | No aggregate health surface beyond per-webhook metrics (webhooks.service.ts:142-156); failure reasons are raw responseBody strings (webhook-delivery-log.schema.ts:28-29) |
| Success metric | Can explain "why is this integration slow" from the metrics tile alone |
3. Platform / Security Reviewer — "Dr. Okafor" (forward-looking)
| Attribute | Detail |
|---|---|
| Role | Tenancy-wide auditor (platform admin bypasses tenant scope, base.repository.ts:20-30) |
| Context | Reviews signature schemes, secret handling, replay risk |
| Goals | Secret rotation policy; timestamped signatures |
| Friction today | Signature has no timestamp / replay window (webhook-delivery.worker.ts:103-105); no rotation endpoint (PATCH only, webhooks.controller.ts:39-43) |
| Status | Drives the security QA items in 14_QA_Checklist.md; persona is a stretch goal, not a shipped surface |
4. Vendor DevOps — "Anjali" (forward-looking)
| Attribute | Detail |
|---|---|
| Role | External SaaS provider consuming StudyLyon events (planned inbound public scope, IMPLEMENTATION_PLAN.md:48) |
| Context | Delivers their webhook to StudyLyon in the future (e.g. test-series integration, IMPLEMENTATION_PLAN.md:856) |
| Status | No receiver exists in source; surfaced only to scope (planned) work - not a current target persona |
Persona - screen mapping
| Persona | Screens (05) |
|---|---|
| Tenant Developer | 1 list, 2 create (event picker), 3 detail, 4 edit, 5 logs, 6 log detail, 7 test, 8 retry |
| School Admin | 1 list, 3 detail (metrics + pause/resume), 5 logs |
| Security Reviewer | 3 detail (secret), 14 QA items |
Notes
- The event picker must present the actual event types emitted by the platform
(
src/infrastructure/bullmq/event-queue-map.ts:6-43) - the API accepts any string, so the client is responsible for offering valid options (create-webhook.dto.ts:20-24). webhook.readgates both the webhook itself and its logs/metrics (webhooks.controller.ts:27-37, 52-56, 72-76) - no separate log permission.
03 - User Journey (Webhooks Module)
- Journey 1 — Register and verify a webhook (Tenant Developer)
- Journey 2 — Event arrives, delivery fails, manual retry
- Journey 3 — Pause, edit, delete (School Admin)
- Journey 4 — External vendor pushes events into StudyLyon
(planned) - Journey map (mermaid)
End-to-end journeys against the implemented backend. Wire contracts quoted from
src/modules/webhooks/**. Journeys 1-2 are implemented; journey 4 is(planned)(inbound receiver).
Journey 1 — Register and verify a webhook (Tenant Developer)
- Developer signs in (web-first surface, post-Phase 1 per
PRODUCT_REQUIREMENTS_DOCUMENT.md:144). - Opens Webhooks list -
GET /api/v1/webhooks(sort newest first,webhooks.service.ts:35-37) - sees existing subscriptions and their enabled/paused state. - Taps "New webhook" -
POST /api/v1/webhooks(webhooks.controller.ts:21-25) with{ name, url, events[1..n], secret, enabled? }(create-webhook.dto.ts:11-34). URL must be valid (@IsUrl({ require_tld: false }),create-webhook.dto.ts:17-18); events array must have at least one entry (ArrayMinSize(1),create-webhook.dto.ts:22-23). - Fires the test ping -
POST /api/v1/webhooks/:id/test(webhooks.controller.ts:65-70) - the worker POSTs{ test: true, webhookId }as eventWebhookTested(webhooks.service.ts:130-138). - Receiver gets
X-Webhook-Signature: <hmac-sha256 hex>+X-Webhook-Event: WebhookTested(webhook-delivery.worker.ts:70-82); developer verifies signature with the stored secret. - Watches
GET /api/v1/webhooks/:id/logs(50 latest,webhooks.service.ts:88-93) andGET /api/v1/webhooks/:id/metrics(webhooks.service.ts:142-156) -total/success/failed/pendingcounts. - Done when the log row shows
status: success(recordDelivery,webhooks.service.ts:179-193).
Journey 2 — Event arrives, delivery fails, manual retry
- A domain event is emitted on the
EventBus(event-bus.service.ts:11-14). WebhooksServiceintercepts viaonAny(webhooks.service.ts:26-28) and queries active subscriptions for that event type - tenantId + enabled + not deleted +events: eventType(webhook.repository.ts:17-29).- One job per matching webhook is enqueued:
deliveron queuewebhook-deliver(queue.constants.ts:14) withattempts: 3and exponential backoff starting at 5 s (webhooks.service.ts:69-84). - Worker creates a
pendingdelivery log (webhooks.service.ts:163-177), POSTs the payload (webhook-delivery.worker.ts:73-82), and recordssuccesson 2xx elsefailed+ rethrows (webhook-delivery.worker.ts:84-100); BullMQ retries. - All attempts exhausted - developer sees
failedin logs and red in metrics. - Developer taps "Retry" -
POST /api/v1/webhooks/:id/retry(webhooks.controller.ts:58-63) - the latest failed delivery is re-enqueued with its original payload (webhooks.service.ts:95-120). - Retry succeeds - log row flips to
success(new row; the failed row stays - one log row per worker attempt,webhooks.service.ts:62-67, 84-89).
Journey 3 — Pause, edit, delete (School Admin)
- Admin pauses a misbehaving integration -
POST /api/v1/webhooks/:id/pause(webhooks.controller.ts:78-83) - fan-out now skips it (webhook.repository.ts:23-28). - Later resumes -
POST /api/v1/webhooks/:id/resume(webhooks.controller.ts:85-90). - Developer edits URL/events/secret -
PATCH /api/v1/webhooks/:idwith any subset ofUpdateWebhookDto(PartialType,update-webhook.dto.ts:4). - Decommission -
DELETE /api/v1/webhooks/:id- soft delete (webhooks.service.ts:50-53,base.repository.ts:68-74); list no longer shows it.
Journey 4 — External vendor pushes events into StudyLyon (planned)
- Vendor signs up for the planned unauthenticated
publicscope (IMPLEMENTATION_PLAN.md:48- "(webhooks, health)", 30 req/min, 1 min window). - Vendor POSTs events to the public receiver - no such endpoint exists in
source; blocked by
IMPLEMENTATION_PLAN.md:856(test-series integration via webhooks). Marked(planned); excluded from all API tables in 12. - Journey ends here until the receiver is implemented.
Journey map (mermaid)
flowchart LR
A[Domain event emitted<br/>event-bus.service.ts:11-14] --> B[onAny fan-out<br/>webhooks.service.ts:26-28]
B --> C[findActiveByEvent<br/>webhook.repository.ts:17-29]
C -->|match| D[enqueue deliver job<br/>attempts 3, backoff 5s<br/>webhooks.service.ts:80-83]
D --> E[worker: pending log<br/>webhooks.service.ts:163-177]
E --> F[POST url + HMAC sig<br/>webhook-delivery.worker.ts:70-82]
F -->|2xx| G[log success]
F -->|non-2xx/timeout| H[log failed + rethrow<br/>:84-100]
H -->|attempts left| D
H -->|exhausted| I[manual retry<br/>POST /:id/retry]
I --> D
04 - Information Architecture (Webhooks Module)
Screens, routes, data entities and cross-references. The module owns two documents:
webhooks(subscriptions) andwebhook_delivery_logs(attempts). Read alongside 00-shared/05 (global IA).
1. IA map
Settings / Developer tools (web-first admin)
└── Webhooks (/webhooks) S1
├── Create webhook (/webhooks/new) S2
├── Webhook detail (/webhooks/:id) S3
│ ├── Edit (/webhooks/:id/edit) S4
│ ├── Delivery logs (/webhooks/:id/logs) S5
│ │ └── Log detail (bottom sheet) S6
│ ├── Test delivery (dialog) S7
│ └── Retry delivery (dialog) S8
└── Pause / Resume (inline action) S9
All routes are client-side; the API is flat at /api/v1/webhooks*
(webhooks.controller.ts:16-17).
2. Information entities
2.1 Webhook (webhooks collection, webhook.schema.ts:8-30)
| Field | Type | Source |
|---|---|---|
name | string, required | webhook.schema.ts:10-11 |
url | string, required (HTTPS expected) | webhook.schema.ts:13-14 |
events | string[], default [] | webhook.schema.ts:16-17 |
secret | string, required | webhook.schema.ts:19-20 |
enabled | boolean, default true | webhook.schema.ts:22-23 |
lastTriggeredAt | Date (unused - never written) | webhook.schema.ts:25-26 |
failureCount | number, default 0 (unused - never written) | webhook.schema.ts:28-29 |
tenantId, isDeleted, version, createdAt, updatedAt | inherited | base.schema.ts:10-34 |
2.2 WebhookDeliveryLog (webhook_delivery_logs collection, webhook-delivery-log.schema.ts:7-39)
| Field | Type | Source |
|---|---|---|
webhookId | ObjectId ref Webhook | webhook-delivery-log.schema.ts:9-10 |
eventType | string, required | :12-13 |
payload | object (raw event payload) | :15-16 |
status | enum pending / success / failed, default pending | :18-23 |
responseCode | number | :25-26 |
responseBody | string | :28-29 |
attemptCount | number, default 0 (never incremented) | :31-32 |
attemptedAt | Date, default now | :34-35 |
completedAt | Date | :37-38 |
2.3 Domain events (the events picker vocabulary)
Event types are free-form strings on the wire (create-webhook.dto.ts:20-24);
the client-side picker vocabulary is the set of emitted events. Representative
registry: event-queue-map.ts:6-43 - UserRegistered, UserLoggedIn,
PasswordResetRequested, UserCreated/Updated/Deleted, OrganizationCreated,
AttendanceMarked/Updated, HomeworkCreated/Updated/Submitted/Graded/Deleted,
ExamResultsPublished, StudentCreated/Updated/Deleted,
TeacherCreated/Updated/Deleted, StaffCreated/Updated/Deleted,
ParentCreated/Updated/Deleted, FeeStructureCreated, InvoiceIssued,
PaymentCompleted. Plus the synthetic test event WebhookTested
(webhooks.service.ts:134).
Event envelope (what the receiver gets): { eventType, tenantId, actorId, occurredAt, correlationId, payload } (domain-event.interface.ts:1-8).
3. Cross-references
| Entity | Referenced by | Ref |
|---|---|---|
| Webhook | WebhookDeliveryLog.webhookId | webhook-delivery-log.schema.ts:9-10 |
| EventBus (any event) | WebhooksService.onAny | webhooks.service.ts:26-28 |
webhook-deliver queue | WebhooksModule + worker | queue.constants.ts:14, webhooks.module.ts:22, webhook-delivery.worker.ts:9 |
| Event names | eventQueueMap | event-queue-map.ts:6-43 |
4. Navigation rules
- S3 is the hub: metrics tile, recent logs preview, actions (edit, test, pause/resume).
- S5 is reachable from S3 and from any "failed" badge deep link.
- Pause/resume and test/retry never require leaving the current screen (dialogs).
- Permission gating:
webhook.create(S2),webhook.update(S4, S7, S8, S9),webhook.read(S1, S3, S5, S6) -permissions.constants.ts:89-92. (planned)future: inbound public receiver underpublicscope (IMPLEMENTATION_PLAN.md:48); not in this IA.
05 - Screen Inventory (Webhooks Module)
- Legend
- 1. Webhooks List (
/webhooks) - 2. Create Webhook (
/webhooks/new) - 3. Webhook Detail (
/webhooks/:id) - 4. Edit Webhook (
/webhooks/:id/edit) - 5. Delivery Logs (
/webhooks/:id/logs) - 6. Delivery Log Detail (bottom sheet from S5)
- 7. Test Delivery (dialog, from S3)
- 8. Retry Delivery (dialog, from S3/S5/S6)
- 9. Pause / Resume (inline confirm from S1/S3)
- Shared components used
- Analytics events (proposed)
- Keyboard, landscape, tablet, desktop
Every screen of the Webhooks module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in 00-shared/03; this file enumerates which ones each screen uses with module specifics.
Legend
States = idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission.
Analytics events follow {module}.{screen}.{action} (proposed; SDK open - 00-shared/10 §8).
1. Webhooks List (/webhooks)
| Field | Detail |
|---|---|
| Purpose | All tenant webhooks, newest first |
| Source | GET /api/v1/webhooks (no pagination, sort createdAt: -1, webhooks.service.ts:35-37) |
| Widgets | AppListTile per webhook: name, url (monospace, clipped), events chips (up to 3 + "+n"), trailing AppBadge (active success / paused neutral), AppMenu (Edit / Pause-Resume / Delete) |
| CTA / FAB | AppFAB "New webhook" (webhook.create) |
| States | loading (skeleton tiles), empty ("No webhooks yet"), error API, permission (webhook.read) |
| Row actions | tap → detail (S3); delete → AppDialog confirm → DELETE /webhooks/:id (webhooks.controller.ts:45-50), optimistic remove, rollback on 404/500 |
| Analytics | webhooks.list.open, webhooks.list.create, webhooks.list.open_detail (proposed) |
2. Create Webhook (/webhooks/new)
| Field | Detail |
|---|---|
| Purpose | Register subscription: name, url, events, secret, enabled |
| Source | POST /api/v1/webhooks (webhooks.controller.ts:21-25); DTO create-webhook.dto.ts:11-34 |
| Inputs | AppTextField name; url (.url keyboard); event picker (chips multi-select from event-queue-map.ts:6-43); secret (obscure + "generate" helper, min-length hint); enabled switch (default on, create-webhook.dto.ts:30-33) |
| Primary CTA | "Create webhook" |
| Validation | name/url/secret required; url @IsUrl({ require_tld: false }) (create-webhook.dto.ts:17-18); ≥1 event (ArrayMinSize(1), :22-23) |
| States | loading, field errors, server error |
| Analytics | webhooks.create.submit, webhooks.create.success, webhooks.create.failure (proposed) |
| Note | Form is the largest surface - full spec in 08_Form_Specifications.md |
3. Webhook Detail (/webhooks/:id)
| Field | Detail |
|---|---|
| Purpose | Single subscription: config + health in one place |
| Source | GET /api/v1/webhooks/:id (webhooks.controller.ts:33-37); metrics GET /webhooks/:id/metrics (:72-76, webhooks.service.ts:142-156) |
| Layout | Header card (name, url, enabled badge, events chips, secret masked with reveal); metrics tile (total / success / failed / pending counts); recent delivery logs preview (5 rows); actions |
| Actions | Edit (S4), Test (S7), Retry (S8, only when failed > 0), Pause/Resume (S9) |
| States | loading skeleton, 404 → AppErrorState + back, error |
| Analytics | webhooks.detail.open, webhooks.detail.tab.{config,logs} (proposed) |
4. Edit Webhook (/webhooks/:id/edit)
| Field | Detail |
|---|---|
| Purpose | Modify url/events/secret/enabled - any subset |
| Source | PATCH /api/v1/webhooks/:id (webhooks.controller.ts:39-43); UpdateWebhookDto = PartialType (all optional, update-webhook.dto.ts:4) |
| Inputs | same as S2, pre-filled from detail doc; secret shown masked, blank = keep unchanged |
| Primary CTA | "Save changes" |
| Note | Secret rotation is a PATCH of secret today - a dedicated rotation flow is (forward-looking) |
5. Delivery Logs (/webhooks/:id/logs)
| Field | Detail |
|---|---|
| Purpose | Per-attempt delivery history, newest first |
| Source | GET /api/v1/webhooks/:id/logs - hard cap 50, sort createdAt: -1 (webhooks.service.ts:88-93) |
| Widgets | AppListTile per log: eventType, status badge (pending amber / success green / failed red), responseCode, attemptedAt; tap → S6 |
| Empty | "No deliveries yet - trigger an event or use Test" |
| Pull-to-refresh | RefreshIndicator re-fetches |
| States | loading, error, empty |
| Analytics | webhooks.logs.open, webhooks.logs.refresh (proposed) |
| Note | One row per worker attempt (retries create new rows, webhooks.service.ts:62-67, 84-89); pagination (forward-looking) |
6. Delivery Log Detail (bottom sheet from S5)
| Field | Detail |
|---|---|
| Purpose | Full attempt payload + response |
| Source | same doc as S5 (payload, responseCode, responseBody, attemptedAt, completedAt - webhook-delivery-log.schema.ts:12-38) |
| Content | eventType, status, timing, responseCode + responseBody (collapsed, monospace), full payload JSON (copyable) |
| Actions | "Retry this webhook" (S8) if failed |
| a11y | payload/code blocks exposed as selectable text, not image |
7. Test Delivery (dialog, from S3)
| Field | Detail |
|---|---|
| Purpose | Ping the endpoint with a synthetic event |
| Source | POST /api/v1/webhooks/:id/test (webhooks.controller.ts:65-70) → queue job with eventType: 'WebhookTested', payload: { test: true, webhookId } (webhooks.service.ts:130-138) |
| Flow | confirm dialog → success snackbar "Test delivery queued" (server returns { message: 'Test delivery queued' }, :69) → user watches S5 |
| Note | Async: dialog must not claim delivery, only enqueue (queue webhook-deliver, queue.constants.ts:14) |
8. Retry Delivery (dialog, from S3/S5/S6)
| Field | Detail |
|---|---|
| Purpose | Re-enqueue the latest failed delivery |
| Source | POST /api/v1/webhooks/:id/retry (webhooks.controller.ts:58-63) → 404 'No failed deliveries to retry' if none (webhooks.service.ts:103-108); job reuses the failed log's payload (:110-118) |
| Precondition | latest log for this webhook has status: 'failed' (webhooks.service.ts:103-106) |
| Flow | dialog "Retry latest failed delivery?" → snackbar "Retry queued" → S5 refresh |
| Note | correlationId is sent as '' on retries (webhooks.service.ts:117) |
9. Pause / Resume (inline confirm from S1/S3)
| Field | Detail |
|---|---|
| Purpose | Flip enabled without editing the record |
| Source | POST /webhooks/:id/pause → setEnabled(false); POST /webhooks/:id/resume → setEnabled(true) (webhooks.controller.ts:78-90, webhooks.service.ts:158-161) |
| Behavior | paused webhooks are skipped by fan-out (webhook.repository.ts:23-28); toggle back via resume |
| Note | No pause vs disabled distinction in the model - single enabled boolean (webhook.schema.ts:22-23) |
Shared components used
AppTextField, AppButton, AppSnackbar, AppCard, AppListTile, AppBottomSheet,
AppDialog, AppMenu, AppSkeleton, AppEmptyState, AppErrorState, AppOfflineBanner,
AppFAB, AppChips, AppBadge, AppSwitch, AppStatTile (metrics). Module-specific:
EventPickerChipField, SecretField, DeliveryLogTile, SignatureVerifyCard
(developer docs) - defined in 07_Component_Library.md.
Analytics events (proposed)
webhooks.list.{open,create,open_detail}, webhooks.create.{submit,success,failure},
webhooks.detail.{open,pause,resume}, webhooks.edit.{submit,success},
webhooks.logs.{open,refresh,retry,test} (all proposed).
Keyboard, landscape, tablet, desktop
- Forms portrait-first with
resizeToAvoidBottomInset; tablet/desktop: list + detail master-detail (S1 → S3), forms centered ≤ 480 dp. - Code blocks (url, payload, responseBody) full-width monospace with horizontal scroll; long payloads collapse by default (S6).
06 - Screen Specifications (Webhooks Module)
- 0. Shared states (every screen)
- 1. Webhooks List (
/webhooks) - 2. Create Webhook (
/webhooks/new) - 3. Webhook Detail (
/webhooks/:id) - 4. Edit Webhook (
/webhooks/:id/edit) - 5. Delivery Logs (
/webhooks/:id/logs) - 6. Delivery Log Detail (bottom sheet)
- 7. Test Delivery (dialog)
- 8. Retry Delivery (dialog)
- 9. Pause / Resume (inline confirm)
- 10. Inbound Webhook Receiver
(planned)
Detailed specifications per screen: layout, wire contract, states, interactions, errors, permissions, a11y and analytics. Read alongside 05_Screen_Inventory.md and 00-shared/03 (components), 00-shared/06 (state), 00-shared/09 (a11y).
0. Shared states (every screen)
| State | Rendering | Source of truth |
|---|---|---|
| idle/loading | AppSkeleton per block; list rows as skeleton tiles | 00-shared/06 §3.1 |
| error offline | AppOfflineBanner + native retry; data shown stale if cached | 00-shared/10 §2 |
| error API | AppErrorState(code, message, onRetry); 401 → re-auth; 403 → permission copy | 00-shared/03 |
| empty | AppEmptyState with CTA | 00-shared/03 |
| 404 | detail screens → AppErrorState + back | webhooks.service.ts:39-43 |
| 403 | hidden actions; if invoked anyway, permission copy | permissions.constants.ts:89-92 |
Error envelope: { statusCode, message, timestamp, path } per 00-shared/07 §3
(HttpExceptionFilter).
1. Webhooks List (/webhooks)
Layout
- AppBar: "Webhooks" + FAB "New webhook" (
.create). AppListTilerows: leading globe icon; titlename; subtitleurl(monospace, ellipsized); chips row: event types (max 3 + "+n"); trailingAppBadge(enabled→ success "Active", else neutral "Paused") +AppMenu(Edit / Pause or Resume / Delete).- Pull-to-refresh; no pagination UI (API returns full list).
Wire contract
GET /api/v1/webhooks(webhooks.controller.ts:27-31); sortcreatedAt: -1(webhooks.service.ts:35-37).- Item shape:
{ _id, name, url, events[], secret, enabled, lastTriggeredAt?, failureCount, tenantId, isDeleted, version, createdAt, updatedAt }(webhook.schema.ts:8-30,base.schema.ts:10-34).secretis present in plaintext - client renders it only in detail; list rows never display it.
States
- loading: 6 skeleton tiles. empty: "No webhooks yet" + "New webhook" CTA.
- error per §0.
Interactions
- Tap row → S3. Menu Edit → S4; Pause/Resume → S9 confirm;
Delete →
AppDialogconfirm →DELETE /api/v1/webhooks/:id(webhooks.controller.ts:45-50) → optimistic remove, rollback on failure; server replies{ message: 'Webhook deleted' }(:49). - FAB → S2.
a11y / motion
- Badge read as part of tile semantics: "Active" / "Paused". Stagger fade
m-base(00-shared/08).
Analytics (proposed)
webhooks.list.open, webhooks.list.create, webhooks.list.open_detail.
2. Create Webhook (/webhooks/new)
Layout
- AppBar: "New webhook", back.
AppCardform (see 08_Form_Specifications.md §1):- Name (
AppTextField, required) - URL (
AppTextField,.urlkeyboard, required) - Events (
EventPickerChipField- multi-select chips, required ≥ 1) - Secret (
SecretField- obscure + generate, required) - Enabled (
AppSwitch, default on)
- Name (
- Primary CTA "Create webhook" (full width), disabled while submitting.
Wire contract
POST /api/v1/webhooks(webhooks.controller.ts:21-25); body =CreateWebhookDto(create-webhook.dto.ts:11-34):name: string(@IsString,:13-14)url: string(@IsUrl({ require_tld: false }),:17-18)events: string[](@IsArray+@ArrayMinSize(1)+@IsString({each:true}),:20-24)secret: string(@IsString,:27-28)enabled?: boolean(@IsOptional+@IsBoolean, default true server-side via schemawebhook.schema.ts:22-23,:30-33)
- Response: created document (envelope
{ data }, 00-shared/07 §2).
States
- submitting → CTA spinner, fields locked.
- success → pop to S3 (new id).
- error 400: field-level messages from class-validator (missing/invalid fields).
- error 401/500:
AppErrorStatein place of form? No - inline snackbar + form stays intact.
Interactions
- Event chips: tap toggles; "Select all" / "Clear" helpers; picker vocabulary from
event-queue-map.ts:6-43(see §0 note in 08). - Secret: "Generate" produces a strong random secret client-side (server has no generator); "Reveal" toggle.
- Enabled switch defaults on (
create-webhook.dto.ts:30-33).
a11y / motion
- Labels linked; first invalid field focused on submit (00-shared/09);
chips have
semanticsLabel"Event selected: X".
Analytics (proposed)
webhooks.create.submit, webhooks.create.success, webhooks.create.failure.
3. Webhook Detail (/webhooks/:id)
Layout
- AppBar: webhook
name; menu → Edit / Delete. - Header
AppCard: status badge (Active/Paused),url(monospace, tappable → copy), events chips,SecretFieldmasked with reveal (server returns plaintext secret; client keeps it in memory only,webhooks.service.ts:39-43). - Metrics tile: 4
AppStatTile- Total / Success / Failed / Pending (webhooks.service.ts:142-156). - "Recent deliveries" preview: last 5 log rows (
DeliveryLogTile), tap → S5. - Action row: "Test" (S7), "Retry" (S8, enabled only when
failed > 0), "Pause"/"Resume" (S9).
Wire contract
GET /api/v1/webhooks/:id(webhooks.controller.ts:33-37); 404 →NotFound(webhooks.service.ts:39-43).GET /api/v1/webhooks/:id/metrics(webhooks.controller.ts:72-76) →{ total, success, failed, pending }via 4 count queries (webhooks.service.ts:149-154).
States
- loading: skeleton card + tiles. 404:
AppErrorState"Webhook not found" + back. - metrics tile: independent per-tile load - one failure keeps the rest visible.
Interactions
- Retry enabled iff
failed > 0; Test always available (.update). - Pause/Resume swap based on
enabled. - Copy url/secret buttons (
Clipboard.setData).
a11y / motion
- StatTile semantics "Total deliveries: 12" etc.; masked secret announced as "Secret, hidden".
Analytics (proposed)
webhooks.detail.open, webhooks.detail.pause, webhooks.detail.resume.
4. Edit Webhook (/webhooks/:id/edit)
Layout
- Same form as S2, pre-filled: url, events, enabled;
nameoptional;secretshown masked with placeholder "Leave blank to keep current secret". - CTA "Save changes".
Wire contract
PATCH /api/v1/webhooks/:id(webhooks.controller.ts:39-43); body =UpdateWebhookDto=PartialType(CreateWebhookDto)- every field optional (update-webhook.dto.ts:4). Empty PATCH body is accepted (no-op).- Response: updated document or null (
webhooks.service.ts:45-48).
States
- as S2; plus 404 handling (webhook deleted meanwhile) → error + back.
Interactions
- Only dirty fields are sent (diff on submit).
- Secret rotation UX: reveal old → type new → save; dedicated rotation flow with
re-verification is
(forward-looking).
Analytics (proposed)
webhooks.edit.submit, webhooks.edit.success, webhooks.edit.rotate_secret.
5. Delivery Logs (/webhooks/:id/logs)
Layout
- AppBar: "Delivery logs"; subtitle = webhook name.
- List of
DeliveryLogTile: eventType (monospace), status badge (pending / success / failed),responseCodewhen present,attemptedAtrelative time; trailing chevron → S6. - Pull-to-refresh; empty state "No deliveries yet - trigger an event or use Test".
Wire contract
GET /api/v1/webhooks/:id/logs(webhooks.controller.ts:52-56); sortcreatedAt: -1, hardlimit: 50(webhooks.service.ts:88-93). No pagination params today.- Log shape:
{ _id, webhookId, eventType, payload, status, responseCode?, responseBody?, attemptCount, attemptedAt, completedAt?, createdAt, updatedAt }(webhook-delivery-log.schema.ts:7-39).
States
- loading, empty, error per §0; one row per worker attempt - retries appear as
new rows (
webhooks.service.ts:62-67, 84-89).
Interactions
- Tap row → S6 sheet. Pull-to-refresh re-fetches.
- Failed rows: "Retry" quick action (S8) without opening the sheet.
a11y / motion
- Status communicated by badge + text (never color alone, 00-shared/09).
Analytics (proposed)
webhooks.logs.open, webhooks.logs.refresh, webhooks.logs.open_detail.
6. Delivery Log Detail (bottom sheet)
Layout
- Header: eventType + status badge + timing (attemptedAt → completedAt).
- Sections: Request (payload JSON, monospace, collapsed by default, copy button);
Response (
responseCode+responseBody, monospace, collapsed;undefined→ "no response recorded"); attempt metadata (attemptCount - always 0 today,webhook-delivery-log.schema.ts:31-32, see 14). - Footer action: "Retry webhook" (S8) when failed.
Wire contract
- No extra endpoint - data already loaded by S5 list call.
States
- payload/response rendering errors (huge/non-JSON body) → truncated view + copy raw.
Analytics (proposed)
webhooks.logs.detail_open.
7. Test Delivery (dialog)
Layout
AppDialog: title "Test webhook"; body "Send a test event to?"; primary "Send test", secondary "Cancel".
Wire contract
POST /api/v1/webhooks/:id/test(webhooks.controller.ts:65-70) → server replies{ message: 'Test delivery queued' }immediately (:69); delivery is async via queue: jobeventType: 'WebhookTested',payload: { test: true, webhookId }(webhooks.service.ts:130-138).- Delivery attempt appears in logs within seconds (
webhook-delivery.worker.ts:62-89).
States / interactions
- submitting → spinner on primary; success → close + snackbar "Test delivery queued".
- After close, navigate to S5 to watch the attempt.
Analytics (proposed)
webhooks.logs.test.
8. Retry Delivery (dialog)
Layout
AppDialog: "Retry latest failed delivery?"; body shows the failed eventType + time; primary "Retry", secondary "Cancel".
Wire contract
POST /api/v1/webhooks/:id/retry(webhooks.controller.ts:58-63).- Server finds latest
failedlog (webhooks.service.ts:103-106); if none →NotFoundException('No failed deliveries to retry')(:107-108) - dialog shows "Nothing to retry" state. - Job re-queued with the failed attempt's eventType + payload
(
webhooks.service.ts:110-118);correlationId: ''(:117). - Reply:
{ message: 'Retry queued' }(webhooks.controller.ts:62).
States / interactions
- success → close + snackbar "Retry queued" → S5 refresh.
- 404 → inline note "No failed deliveries to retry" + close.
Analytics (proposed)
webhooks.logs.retry.
9. Pause / Resume (inline confirm)
Layout
AppDialogor inlineAppMenuconfirm: "Pause this webhook?" / "Resume?".
Wire contract
POST /api/v1/webhooks/:id/pause→setEnabled(false)(webhooks.controller.ts:78-83,webhooks.service.ts:158-161).POST /api/v1/webhooks/:id/resume→setEnabled(true)(webhooks.controller.ts:85-90).- Replies:
{ message: 'Webhook paused' }/{ message: 'Webhook resumed' }. - Effect on fan-out:
findActiveByEventfiltersenabled: true(webhook.repository.ts:23-28) - paused webhooks stop receiving immediately.
States / interactions
- optimistic badge flip + snackbar; rollback on 404/500.
Analytics (proposed)
webhooks.detail.pause, webhooks.detail.resume.
10. Inbound Webhook Receiver (planned)
- No screen and no endpoint in source. Planned as unauthenticated
publicscope ("(webhooks, health)", 30 req/min, 1 min window,IMPLEMENTATION_PLAN.md:48). - When implemented (e.g. test-series integration,
IMPLEMENTATION_PLAN.md:856), this screen would show tenant-scoped receipts of inbound vendor events.
07 - Component Library (Webhooks Module)
- 1.
EventPickerChipField - 2.
SecretField - 3.
DeliveryLogTile - 4.
MetricsTile(4-stat grid) - 5.
SignatureVerifyCard(developer helper)(forward-looking) - Composition map (screen → module widgets)
Module-specific widgets on top of 00-shared/03 (authoritative component library). Shared components (
AppTextField,AppButton,AppDialog,AppSnackbar,AppCard,AppListTile,AppMenu,AppChips,AppBadge,AppFAB,AppSwitch,AppStatTile,AppSkeleton,AppEmptyState,AppErrorState,AppBottomSheet) are reused as-is; this file adds only what the module needs that shared does not define.
1. EventPickerChipField
| Attribute | Value |
|---|---|
| Purpose | Multi-select of domain event types (the webhook's events array) |
| Data | vocabulary from event-queue-map.ts:6-43 + WebhookTested (webhooks.service.ts:134); stored value string[] (webhook.schema.ts:16-17) |
| Props | selected: Set<String>, onChanged, allowCustom: bool = false, maxVisible: int = 12 |
| Behaviour | AppChips filterable grid; free-text "custom event" input chip when allowCustom (API accepts any string - create-webhook.dto.ts:20-24); grouped by source module (users/attendance/homework/results/students/teachers/staff/parents/fees/payments, event-queue-map.ts:6-43) |
| Validation | ≥ 1 selected (mirror @ArrayMinSize(1), create-webhook.dto.ts:22-23) |
| a11y | each chip semanticsLabel "Event X, selected/unselected"; group headers as Semantics headers |
2. SecretField
| Attribute | Value |
|---|---|
| Purpose | Signing-secret input with generate/reveal (create + edit + detail) |
| Props | value, onChanged, obscured: bool = true, onGenerate, canReveal |
| Behaviour | obscure toggle (eye icon); "Generate" fills a strong random secret (client-side only - no server generator); copy button on detail read-only variant |
| Security note | secret is stored plaintext and returned by API (webhooks.service.ts:31-47); field never logs; auto-clear clipboard after 30 s (proposed) |
| Validation | required on create (@IsString, create-webhook.dto.ts:27-28); blank = keep on edit |
3. DeliveryLogTile
| Attribute | Value |
|---|---|
| Purpose | One delivery attempt row (S5 list, S3 preview) |
| Data | log doc: eventType, status, responseCode?, attemptedAt, completedAt? (webhook-delivery-log.schema.ts:12-38) |
| Layout | leading status icon (pending clock / success check / failed alert), title eventType, subtitle responseCode · attemptedAt, trailing chevron |
| States | badge color per status: pending amber, success green, failed red - always with text (00-shared/09) |
| Interactions | tap → detail sheet; optional quick "Retry" on failed rows |
| Note | attemptCount exists on the schema (:31-32) but is always 0 today - tile hides it until the worker increments it (14_QA_Checklist.md G-2) |
4. MetricsTile (4-stat grid)
| Attribute | Value |
|---|---|
| Purpose | Health summary on S3 |
| Data | { total, success, failed, pending } from GET /webhooks/:id/metrics (webhooks.service.ts:142-156) |
| Layout | 2×2 AppStatTile grid; failed tile highlighted when > 0 with "Retry" affordance |
| States | per-tile load/error - one failure never blanks the grid |
5. SignatureVerifyCard (developer helper) (forward-looking)
| Attribute | Value |
|---|---|
| Purpose | Client-side verification of a captured signature against a body+secret |
| Algorithm | HMAC-SHA256 hex digest of raw request body (webhook-delivery.worker.ts:103-105) - mirrors the worker's sign() |
| Use | paste body + signature + secret → "Valid ✓ / Invalid ✗" |
| Note | documentation aid; not required by the API - receiver side is external. Signature has no timestamp/replay window today (QA G-4) |
Composition map (screen → module widgets)
| Screen (05) | Module widgets |
|---|---|
| S2/S4 | EventPickerChipField, SecretField |
| S3 | SecretField (read-only), MetricsTile |
| S5 | DeliveryLogTile |
| S6 | payload/response code blocks (monospace selectable text) |
| S3/S5/S6 | SignatureVerifyCard (dev mode) |
08 - Form Specifications (Webhooks Module)
- 1. Webhook form (S2 create / S4 edit)
- 2. Wire payloads
- 3. Event picker vocabulary (client-side)
- 4. Client-side validation order
- 5. Submit behaviour
- 6. Secret handling
- 7. Pause / Resume (non-form action)
The webhook form (create + edit share one model). Exact DTO contracts quoted from
create-webhook.dto.ts/update-webhook.dto.ts; validation mirrors class-validator rules so client and server agree.
1. Webhook form (S2 create / S4 edit)
| Field | Control | Required | Rules (server = source) | Server source |
|---|---|---|---|---|
name | AppTextField | yes (create) / optional (edit) | non-empty string | @IsString, create-webhook.dto.ts:13-14 |
url | AppTextField (.url keyboard) | yes / optional | valid URL; require_tld: false (local/private hosts allowed) | @IsUrl({ require_tld: false }), :17-18 |
events | EventPickerChipField | yes (≥ 1) | array of strings, min size 1 | @IsArray + @ArrayMinSize(1) + @IsString({ each: true }), :20-24 |
secret | SecretField | yes (create) / blank = keep (edit) | non-empty string | @IsString, :27-28 |
enabled | AppSwitch | no | boolean, default true | @IsOptional + @IsBoolean, :30-33; schema default webhook.schema.ts:22-23 |
Edit mode: UpdateWebhookDto = PartialType(CreateWebhookDto) - every field
optional (update-webhook.dto.ts:4); client sends only dirty fields.
2. Wire payloads
// POST /api/v1/webhooks (create-webhook.dto.ts:11-34)
{
"name": "SMS Gateway",
"url": "https://vendor.example.com/hooks/sms",
"events": ["AttendanceMarked", "HomeworkSubmitted"],
"secret": "whsec_9f2c…",
"enabled": true
}
// PATCH /api/v1/webhooks/:id (update-webhook.dto.ts:4)
{ "url": "https://vendor.example.com/hooks/sms/v2", "enabled": false }
3. Event picker vocabulary (client-side)
The API accepts any string, so the picker offers the emitted event registry
(event-queue-map.ts:6-43) grouped by module, plus WebhookTested
(webhooks.service.ts:134):
| Group | Events (event-queue-map.ts) |
|---|---|
| Auth / users | UserRegistered, UserLoggedIn, PasswordResetRequested, UserCreated, UserUpdated, UserDeleted (:7-12) |
| Organization | OrganizationCreated (:13) |
| Attendance | AttendanceMarked, AttendanceUpdated (:14-21) |
| Homework | HomeworkCreated, HomeworkUpdated, HomeworkSubmitted, HomeworkGraded, HomeworkDeleted (:22-26) |
| Results | ExamResultsPublished (:27) |
| People | StudentCreated/Updated/Deleted, TeacherCreated/Updated/Deleted, StaffCreated/Updated/Deleted, ParentCreated/Updated/Deleted (:28-39) |
| Fees / payments | FeeStructureCreated, InvoiceIssued, PaymentCompleted (:40-42) |
| Test | WebhookTested (test ping, webhooks.service.ts:134) |
"Custom event" input (optional, allowCustom): free text - matching is exact
string equality (webhook.repository.ts:27), typos silently never fire.
4. Client-side validation order
nameempty → "Name is required".urlnot parseable / no scheme → "Enter a valid URL" (mirror@IsUrl,create-webhook.dto.ts:17-18).eventsempty → "Select at least one event" (mirror@ArrayMinSize(1),:22-23).secretempty (create) → "Secret is required" (mirror@IsString,:27-28).enabledboolean coercion (switch) - no text validation.- Server 400s: map class-validator
messagearrays to fields; unknown messages → top-of-form error.
5. Submit behaviour
- Create:
POST /api/v1/webhooks(webhooks.controller.ts:21-25) → on success pop to detail (S3); on 400 keep form + field errors; on 401/500AppErrorState. - Edit:
PATCH /api/v1/webhooks/:id(webhooks.controller.ts:39-43) → same, pop to S3 with refreshed doc; 404 → error state (webhook deleted elsewhere). - Both: submitting lock (CTA spinner, fields disabled); no double submit.
6. Secret handling
- Create: required; "Generate" helper fills a strong random value client-side.
- Edit: blank = keep unchanged (never echo the stored secret into the field; show placeholder "Leave blank to keep current secret").
- Detail (S3): masked with reveal; copy-to-clipboard.
- Security: secret is stored and returned in plaintext by the API
(
webhooks.service.ts:31-47); rotation = PATCHsecret. Dedicated rotation flow with verification event(forward-looking).
7. Pause / Resume (non-form action)
- No form:
POST /webhooks/:id/pause→setEnabled(false),POST /webhooks/:id/resume→setEnabled(true)(webhooks.controller.ts:78-90;webhooks.service.ts:158-161). - Paused ≠ deleted: fan-out filter drops it (
webhook.repository.ts:23-28).
09 - User Behaviour (Webhooks Module)
- 1. Behavioural contract (what the user sees must match the server)
- 2. Create → verify loop (developer)
- 3. Failure → diagnose → retry loop
- 4. Pause → vendor fix → resume loop (admin)
- 5. Behavioural gaps (flagged, see 14)
Expected behaviour patterns mapped to the implemented backend. Section 1 enumerates the observable server-side states users depend on; §2-§4 describe behaviour loops; §5 lists behavioural gaps.
1. Behavioural contract (what the user sees must match the server)
| User-visible fact | Server source |
|---|---|
| List is newest-first | findAll sort { createdAt: -1 } (webhooks.service.ts:35-37) |
| Logs are newest-first, max 50 | findLogs limit 50 (webhooks.service.ts:88-93) |
Statuses: pending, success, failed only | enum (webhook-delivery-log.schema.ts:18-23) |
"Active" vs "Paused" = enabled | webhook.schema.ts:22-23, pause/resume (webhooks.controller.ts:78-90) |
| Metrics counts match logs | both from same collection (webhooks.service.ts:142-156) |
| Test/retry replies are "queued", not "delivered" | { message: 'Test delivery queued' }, { message: 'Retry queued' } (webhooks.controller.ts:62, 69) |
| Retry works only when a failure exists | NotFoundException('No failed deliveries to retry') (webhooks.service.ts:103-108) |
| Deleting is soft (logs survive) | repo.softDelete (webhooks.service.ts:50-53, base.repository.ts:68-74) |
2. Create → verify loop (developer)
- Form submit → success → test ping → watch logs go
pending → success. - Pattern: developer expects the test attempt within seconds; if the logs stay
empty after ~15 s, surface "queued but not yet processed" (queue is async,
webhooks.service.ts:69-84). - Signature verification happens out-of-band on the receiver side - the app
offers
SignatureVerifyCard(dev mode) for quick checks (webhook-delivery.worker.ts:103-105).
3. Failure → diagnose → retry loop
- Metrics show
failed > 0or a red log row. - Open S6:
responseCode+responseBodyexplain (e.g.404on stale URL). - Fix URL (PATCH,
webhooks.controller.ts:39-43) → retry (POST /webhooks/:id/retry) → new attempt row. - Note: retry re-sends the old payload captured at failure time
(
webhooks.service.ts:110-118) - users should expect historical data, not a fresh event. - If retry fails again, BullMQ gave it 3 attempts already
(
webhooks.service.ts:80-83) - persistent failure = endpoint problem, not a queue problem.
4. Pause → vendor fix → resume loop (admin)
- Vendor incidents → pause (stops fan-out immediately,
webhook.repository.ts:23-28) → vendor fixes → resume → backfills happen manually via the vendor's own replay (no replay API today -(planned)gap). - Users should know: paused webhooks drop events silently; no queue accumulates.
5. Behavioural gaps (flagged, see 14)
| Gap | Evidence | User impact |
|---|---|---|
| No replay/backfill API | only retry-latest-failure exists (webhooks.service.ts:95-120) | missed events during downtime are gone |
| Retry creates a duplicate log rather than updating the failed one | worker always startDelivery (new pending row, :163-177) | log history mixes attempts |
attemptCount stuck at 0 | schema default, never incremented (webhook-delivery-log.schema.ts:31-32) | no per-attempt numbering |
lastTriggeredAt / failureCount never written | fields exist (webhook.schema.ts:25-29), no writes found | cannot sort by last activity server-side |
| HTTP-error double record | worker records failed w/ code then catch records failed w/o (webhook-delivery.worker.ts:84-99) | responseCode may be lost on failure path |
| Events free-form | create-webhook.dto.ts:20-24 | typos silently never fire |
10 - Interaction Specification (Webhooks Module)
- 1. Async action semantics (the core interaction)
- 2. Screen transitions
- 3. In-screen interactions
- 4. Waiting states (async delivery)
- 5. Error interactions
- 6. a11y & motion-safety
Interaction/motion contract for webhook screens. Global motion tokens in 00-shared/08 (
m-fast150 ms,m-base250 ms,m-slow400 ms); this file defines module-specific interactions and the async delivery UX.
1. Async action semantics (the core interaction)
Every destructive/async action (test, retry, pause, resume, delete) follows one pattern because the server is always async: the HTTP call enqueues, never delivers.
| Action | HTTP | Server work (async) | UX response |
|---|---|---|---|
| Test | POST /webhooks/:id/test (webhooks.controller.ts:65-70) | worker POSTs WebhookTested (webhooks.service.ts:130-138) | snackbar "Test delivery queued" → offer "Watch logs" |
| Retry | POST /webhooks/:id/retry (:58-63) | re-queue failed payload (webhooks.service.ts:110-118) | snackbar "Retry queued" → open/refresh logs |
| Pause | POST /webhooks/:id/pause (:78-83) | setEnabled(false) (webhooks.service.ts:158-161) | badge flips instantly, snackbar |
| Resume | POST /webhooks/:id/resume (:85-90) | setEnabled(true) | badge flips instantly, snackbar |
| Delete | DELETE /webhooks/:id (:45-50) | soft delete (webhooks.service.ts:50-53) | confirm dialog → optimistic row removal |
Rules:
- Never claim completion for test/retry (server says "queued",
:62, 69). - After test/retry, navigate to S5 or show a "Watch delivery" action.
- Pause/resume are optimistic with rollback on 404/500.
2. Screen transitions
| From | To | Trigger | Motion (00-shared/08) |
|---|---|---|---|
| S1 list | S2 create | FAB | push slide m-base |
| S1 list | S3 detail | tile tap | push slide m-base |
| S3 detail | S4 edit | menu/action | push slide m-base |
| S3 detail | S5 logs | preview tap / "View all" | push slide m-base |
| S5 logs | S6 log detail | tile tap | bottom sheet, m-base rise |
| S3/S5/S6 | dialogs (S7/S8) | action button | fade + scale m-fast |
| Any | snackbar | async reply | m-fast entrance, auto-dismiss 4 s |
3. In-screen interactions
- Event picker (S2/S4): chip tap toggles with
m-fastscale feedback; group expand/collapsem-fast; "Select all" bulk toggle. - Secret field: reveal/obscure icon swap
m-fast; generate → text replace withm-fastfade; copy → snackbar "Copied" (m-fast). - Logs (S5): pull-to-refresh; new rows fade-in
m-base; failed rows shake briefly on retry successm-fast(subtle, 00-shared/08). - Metrics (S3): counts tick-animate on refresh (
m-base); failed count pulses when > 0 (attention, 00-shared/08 motion-safe). - Status badge transitions: Active ↔ Paused cross-fade
m-fast.
4. Waiting states (async delivery)
- While a test/retry is in flight, the logs screen shows a subtle "processing"
indicator only if the newest row is
pending(webhook-delivery-log.schema.ts:22-23); no infinite spinners - apendingrow is self-explanatory. - If no new row appears within 20 s, show non-blocking hint "Still queued? Check network or retry" (queue health is out of client control).
5. Error interactions
| Error | Presentation | Recovery |
|---|---|---|
| 400 validation | inline field errors, first invalid focused | fix + resubmit |
| 404 webhook | AppErrorState + back (S3/S4/S5) | re-open from list |
| 404 retry | dialog note "No failed deliveries to retry" (webhooks.service.ts:107-108) | dismiss |
| 401 | global re-auth flow (00-shared/06) | sign in |
| 403 | actions hidden; if invoked, permission copy | contact admin |
| 500 / offline | AppErrorState / AppOfflineBanner | retry button |
6. a11y & motion-safety
- All status changes announced via live regions (badge, snackbar, dialog result).
- Motion-reduce: disable shake/pulse; keep cross-fades (00-shared/08 §7, 00-shared/09).
- Touch targets ≥ 48 dp; chip hit areas padded.
11 - Design System Mapping (Webhooks Module)
- 1. Semantic status mapping (delivery health)
- 2. Type ramp
- 3. Spacing & layout
- 4. Iconography
- 5. Dark mode / contrast
- 6. Motion tokens (00-shared/08)
- 7. Component mapping table
Module → design-system mapping. Tokens in 00-shared/02, components in 00-shared/03, mapping method in 00-shared/04. This file adds webhook specifics only.
1. Semantic status mapping (delivery health)
| Domain state | Token role | Component usage |
|---|---|---|
success (webhook-delivery-log.schema.ts:20) | success | AppBadge success; metric green |
failed (same) | danger | AppBadge danger; metric red + pulse; retry affordance |
pending (same) | warning | AppBadge warning amber; metric amber |
webhook enabled: true (webhook.schema.ts:22-23) | success | badge "Active" |
webhook enabled: false | neutral | badge "Paused" |
| no deliveries yet | neutral | empty state icon |
Rules: color never alone - text label always present (00-shared/09).
2. Type ramp
| Content | Token (00-shared/02) |
|---|---|
| Screen titles / AppBar | text-lg / text-xl semibold |
| Webhook name (list, detail) | text-md semibold |
| URL | text-sm monospace (fontMono), muted |
| Event chips | text-sm label in chip |
| Delivery log eventType | text-sm monospace |
| Payload / responseBody code blocks | text-xs monospace, surface-muted background |
| Metrics counts | text-2xl tabular-nums |
3. Spacing & layout
- List rows: 16 dp padding, 8 dp between sections.
- Create/edit form:
AppCard16 dp padding, field spacing 16 dp. - Metrics grid: 2 columns (12/24) on phone, 4 on tablet.
- Dialog width ≤ 360 dp; bottom sheet ≤ 80% height (payload scroll).
4. Iconography
| Concept | Icon |
|---|---|
| Webhook (list tile leading) | globe/webhook glyph |
| Active | check-circle |
| Paused | pause-circle |
| Delivery status icons | clock (pending), check (success), alert (failed) |
| Actions | send (test), replay (retry), eye/eye-off (secret), copy |
5. Dark mode / contrast
- Code blocks:
surface-mutedwithtext-on-surface; status badges keep token pairs - no raw hex anywhere (00-shared/02). - Monospace URL contrast ≥ 4.5:1 on both themes.
6. Motion tokens (00-shared/08)
| Interaction | Token |
|---|---|
| chip toggle, snackbar, badge cross-fade | m-fast (150 ms) |
| screen push, sheet rise, row fade-in | m-base (250 ms) |
| failed-count attention pulse | m-slow (400 ms), disabled on motion-reduce |
7. Component mapping table
| Module widget (07) | Base components | Shared counterpart |
|---|---|---|
EventPickerChipField | AppChips grid + AppTextField (custom input) | 00-shared/03 chips |
SecretField | AppTextField + icon buttons | - |
DeliveryLogTile | AppListTile + AppBadge | - |
MetricsTile | AppStatTile × 4 | 00-shared/03 stats |
SignatureVerifyCard | AppCard + monospace blocks | - |
12 - API Mapping (Webhooks Module)
- 1. Subscriptions CRUD (
webhooks.controller.ts:21-50) - 2. Operations (
webhooks.controller.ts:52-90) - 3. Delivery wire contract (outbound, server → subscriber)
- 4. Request examples
- 5. Response shapes
- 6. Error map
- 7. Planned / not yet in source
Exact wire contracts for the Webhooks module. Base path
/api/v1(URI versioning,main.ts); endpoints carry@Permissionsdecorators (webhooks.controller.ts:22, 28, 34, 40, 46, 53, 59, 66, 73, 79, 86) backed bypermissions.constants.ts:89-92(guards on endpoints not yet wired -AGENTS.md). Tenant-scoped viaBaseRepository(base.repository.ts:20-30). Envelopes per 00-shared/07 §2-3. All paths below are prefixed/api/v1/webhooksunless noted.
1. Subscriptions CRUD (webhooks.controller.ts:21-50)
| Method | Path | Permission | DTO / source | Description |
|---|---|---|---|---|
| POST | /api/v1/webhooks | webhook.create | CreateWebhookDto (create-webhook.dto.ts:11-34) | Create subscription; returns full doc incl. plaintext secret |
| GET | /api/v1/webhooks | webhook.read | - | List all, sort createdAt: -1, unpaginated (webhooks.service.ts:35-37) |
| GET | /api/v1/webhooks/:id | webhook.read | - | Get by id; 404 'Webhook not found' (webhooks.service.ts:39-43) |
| PATCH | /api/v1/webhooks/:id | webhook.update | UpdateWebhookDto (PartialType - all optional, update-webhook.dto.ts:4) | Update any subset; 404 if missing (webhooks.service.ts:45-48) |
| DELETE | /api/v1/webhooks/:id | webhook.delete | - | Soft delete; 200 { message: 'Webhook deleted' } (webhooks.controller.ts:45-50, webhooks.service.ts:50-53) |
2. Operations (webhooks.controller.ts:52-90)
| Method | Path | Permission | Source | Description |
|---|---|---|---|---|
| GET | /api/v1/webhooks/:id/logs | webhook.read | webhooks.service.ts:88-93 | Delivery attempts, newest first, hard limit 50; no pagination params |
| POST | /api/v1/webhooks/:id/retry | webhook.update | webhooks.service.ts:95-120 | Re-queue latest failed attempt (404 'No failed deliveries to retry' if none, :107-108); 200 { message: 'Retry queued' } |
| POST | /api/v1/webhooks/:id/test | webhook.update | webhooks.service.ts:122-140 | Enqueue synthetic delivery (eventType: 'WebhookTested', payload: { test: true, webhookId }); 200 { message: 'Test delivery queued' } |
| GET | /api/v1/webhooks/:id/metrics | webhook.read | webhooks.service.ts:142-156 | { total, success, failed, pending } counts; 404 if webhook missing |
| POST | /api/v1/webhooks/:id/pause | webhook.update | webhooks.service.ts:158-161 | setEnabled(false); 200 { message: 'Webhook paused' } |
| POST | /api/v1/webhooks/:id/resume | webhook.update | webhooks.service.ts:158-161 | setEnabled(true); 200 { message: 'Webhook resumed' } |
3. Delivery wire contract (outbound, server → subscriber)
Worker POST (not a client API, but part of the contract consumers must handle):
| Field | Value | Source |
|---|---|---|
| Method | POST | webhook-delivery.worker.ts:73 |
Content-Type | application/json | :76 |
X-Webhook-Signature | HMAC-SHA256 hex of raw body using webhook.secret | :77, 103-105 |
X-Webhook-Event | eventType | :78 |
| Body | JSON.stringify(payload) (the DomainEvent.payload, domain-event.interface.ts:7) | :70, 80 |
| Timeout | AbortSignal.timeout(10000) - 10 s | :81 |
| Retries | 3 attempts, exponential backoff 5 s (job options) | webhooks.service.ts:80-83 |
| Success | 2xx → log success with responseCode + response body | :84-89 |
4. Request examples
POST /api/v1/webhooks
{ "name": "SMS Gateway", "url": "https://vendor.example.com/hooks/sms",
"events": ["AttendanceMarked", "HomeworkSubmitted"],
"secret": "whsec_9f2c…", "enabled": true }
PATCH /api/v1/webhooks/64f…
{ "events": ["AttendanceMarked", "PaymentCompleted"], "enabled": false }
POST /api/v1/webhooks/64f…/retry → { "message": "Retry queued" }
POST /api/v1/webhooks/64f…/test → { "message": "Test delivery queued" }
GET /api/v1/webhooks/64f…/metrics → { "data": { "total": 9, "success": 7, "failed": 1, "pending": 1 } }
5. Response shapes
- List/single/created/updated:
{ data: <Doc> }(envelope interceptor,00-shared/07). Webhook doc fields:_id, name, url, events, secret, enabled, lastTriggeredAt?, failureCount, tenantId, isDeleted, version, createdAt, updatedAt(webhook.schema.ts:8-30,base.schema.ts:10-34). - Logs:
{ data: DeliveryLog[] }; fields_id, webhookId, eventType, payload, status, responseCode?, responseBody?, attemptCount, attemptedAt, completedAt?(webhook-delivery-log.schema.ts:7-39). - Action endpoints:
{ message: string }.
6. Error map
| Code | Meaning | Source |
|---|---|---|
| 401 | unauthenticated (JWT guard pending wiring - AGENTS.md) | webhooks.controller.ts:21-90 |
| 403 | missing webhook.* permission | permissions.constants.ts:89-92 |
| 400 | DTO validation (class-validator) | create-webhook.dto.ts:11-34 |
| 404 | webhook missing | webhooks.service.ts:41, 46, 51 |
| 404 | retry with no failed delivery | webhooks.service.ts:107-108 |
| 500 | queue enqueue / DB failure | - |
7. Planned / not yet in source
| Path / feature | Status | Source |
|---|---|---|
| Inbound public webhook receiver | (planned) | IMPLEMENTATION_PLAN.md:48 ("(webhooks, health)", 30 req/min, 1 min window) |
Logs pagination/filtering (?page&limit&status) | (planned) - hard cap 50 today | webhooks.service.ts:88-93 |
| Secret rotation endpoint (generate + rotate in one call) | (planned) - PATCH only | webhooks.controller.ts:39-43 |
| Replay/backfill of missed events | (planned) - only latest-failure retry | webhooks.service.ts:95-120 |
X-Webhook-Timestamp / replay protection | (planned) - body-only signature | webhook-delivery.worker.ts:103-105 |
| Test-series vendor integration via webhooks | (planned) | IMPLEMENTATION_PLAN.md:856 |
Event-type registry endpoint (GET /events) | (planned) - picker vocabulary is static | event-queue-map.ts:6-43 |
13 - State Management (Webhooks Module)
- 1. Cubit map
- 2. WebhookListCubit
- 3. WebhookDetailCubit (config + metrics)
- 4. Delivery/Retry state machine (the core diagram)
- 5. DeliveryLogListCubit
- 6. WebhookFormCubit
- 7. Cross-cutting
Per-screen Cubit/Bloc design on top of 00-shared/06 conventions (stack:
flutter_bloc+get_it; server state via dio repository;LoadState= Initial/Loading/Success/Error(ApiException)). Mermaid diagrams included.
1. Cubit map
| Cubit | Screen(s) (05) | Data |
|---|---|---|
WebhookListCubit | 1 | List<Webhook>, per-row actions |
WebhookFormCubit | 2/4 | form model (name, url, events, secret, enabled), field errors, submit |
WebhookDetailCubit | 3 | Webhook, metrics {total, success, failed, pending} |
DeliveryLogListCubit | 3 preview / 5 | List<DeliveryLog> (≤ 50, webhooks.service.ts:88-93) |
WebhookActionCubit | 7/8/9 | test / retry / pause / resume async actions |
Repositories (WebhookRepository, DeliveryLogRepository in
features/webhooks/data/) are the only layer touching HTTP; they map envelopes
to models and throw ApiException(status, message) (00-shared/06 §2-3).
Models: Webhook (webhook.schema.ts:8-30), DeliveryLog
(webhook-delivery-log.schema.ts:7-39).
2. WebhookListCubit
stateDiagram-v2
[*] --> Initial
Initial --> Loading: fetch()
Loading --> Success: GET /webhooks (200)
Loading --> Error: 401/500
Success --> Loading: pullToRefresh
Success --> Error: refetch fails (keep stale)
Success --> Success: delete OK (optimistic)
Success --> Success: delete 404/500 (rollback + snackbar)
- Fetch:
GET /api/v1/webhooks- no pagination, sortcreatedAt: -1(webhooks.service.ts:35-37). - Delete flow: confirm dialog →
DELETE /api/v1/webhooks/:id(webhooks.controller.ts:45-50) → optimistic row removal; rollback on error.
3. WebhookDetailCubit (config + metrics)
sequenceDiagram
participant S as Screen
participant D as WebhookDetailCubit
participant R as Repo
participant A as API
S->>D: load(id)
D->>R: getById(id) + metrics(id)
R->>A: GET /webhooks/:id | GET /webhooks/:id/metrics
A-->>R: doc | {total, success, failed, pending}
R-->>D: Webhook + Metrics
D-->>S: Success(doc, metrics) / Error(404)
- Independent per-block load: webhook doc and metrics each have their own
LoadState; metrics failure never blanks config (webhooks.service.ts:142-156). - Pause/resume:
WebhookActionCubit→POST /webhooks/:id/pause|resume(webhooks.controller.ts:78-90) → optimisticenabledflip, rollback on error (webhooks.service.ts:158-161).
4. Delivery/Retry state machine (the core diagram)
stateDiagram-v2
[*] --> Idle
Idle --> Enqueueing: test() / retryLatest()
Enqueueing --> Queued: 200 {message: "…queued"}
Enqueueing --> NoFailure: 404 "No failed deliveries to retry" (retry only)
Enqueueing --> Error: 401/500
Queued --> PendingRow: watch logs → newest row status=pending
PendingRow --> SuccessRow: worker 2xx → status=success
PendingRow --> FailedRow: worker non-2xx/timeout → status=failed
FailedRow --> Enqueueing: retryLatest() (manual retry)
FailedRow --> Exhausted: 3 BullMQ attempts used (job rethrow)
Exhausted --> Enqueueing: manual retry re-queues same payload
Queued --> Stale: no row after 20s → "Still queued?" hint
Stale --> PendingRow: row appears
- Test:
POST /webhooks/:id/test(webhooks.controller.ts:65-70) - jobeventType: 'WebhookTested'(webhooks.service.ts:130-138). - Retry:
POST /webhooks/:id/retry(:58-63); server requires a latestfailedlog (webhooks.service.ts:103-108); re-queues that attempt's original payload withcorrelationId: ''(:110-118). - Worker side (server truth): attempts 3 / exponential backoff 5 s
(
webhooks.service.ts:80-83); 10 s fetch timeout (webhook-delivery.worker.ts:81); status transitionspending → success|failed(webhooks.service.ts:163-193,webhook-delivery-log.schema.ts:18-23). - Client implication: a
Queuedstate is terminal for the HTTP action; all delivery transitions are observed via logs polling, not HTTP responses.
5. DeliveryLogListCubit
stateDiagram-v2
[*] --> Initial
Initial --> Loading: fetch(webhookId)
Loading --> Loaded(list): GET /webhooks/:id/logs (≤ 50)
Loading --> Error: 401/500
Loaded --> Loading: pullToRefresh / afterTest / afterRetry
Loaded --> DetailSheet: tap row (no extra fetch)
- Logs endpoint: sort
createdAt: -1, limit 50 (webhooks.service.ts:88-93). - After test/retry the cubit refreshes on a short delay (2-3 s) then again on
user pull; a
pendingnewest row keeps a light "processing" indicator (webhook-delivery-log.schema.ts:22-23).
6. WebhookFormCubit
stateDiagram-v2
[*] --> Idle
Idle --> Validating: submit()
Validating --> Submitting: client valid
Validating --> FieldError: invalid (name/url/events/secret)
Submitting --> Done: POST 201 / PATCH 200 (pop to detail)
Submitting --> ServerError: 400 → field messages; 401/500 → error state
Done --> [*]
- Create:
POST /api/v1/webhooks(webhooks.controller.ts:21-25) body fromCreateWebhookDto(create-webhook.dto.ts:11-34). - Edit:
PATCH /api/v1/webhooks/:idwith dirty fields only (UpdateWebhookDto,update-webhook.dto.ts:4); 404 → error state. - Server 400 messages mapped to fields (mirrors class-validator rules,
08§4).
7. Cross-cutting
- Cache: webhook list cached in memory per tenant; detail/logs read
cache-first then refresh (offline tolerance,
00-shared/10§2). - Events as hints: the client does NOT consume the domain-event stream for webhook UI; server state is the only truth (logs polling).
- Permission gating: cubits expose
canCreate/canRead/canUpdate/canDeletefrom RBAC (permissions.constants.ts:89-92); UI hides FAB/actions/menus accordingly (e.g. no "New webhook" withoutwebhook.create). - Secret handling: secret kept only in memory in DetailCubit; never cached
to disk; forms never pre-fill it (blank = keep,
08§6). - Planned cubits: inbound-receiver list
(planned)perIMPLEMENTATION_PLAN.md:48- no data contract yet.
14 - QA Checklist (Webhooks Module)
- 1. Endpoint contract (C)
- 2. Delivery pipeline (C)
- 3. Signature verification (C)
- 4. Retry & test (H)
- 5. Payload size & robustness (M)
- 6. Schema/bookkeeping gaps (M)
- 7. Resilience (M)
- 8. Manual QA script (smoke)
Test plan for the implemented backend surface. Baseline conventions in 00-shared/10; module items below are specific to webhook delivery semantics. Each item cites its source contract. Severity: C = critical, H = high, M = medium.
1. Endpoint contract (C)
| # | Check | Expected | Source |
|---|---|---|---|
| E-1 | CRUD happy path | POST → 201 doc; GET list newest-first; GET :id; PATCH subset; DELETE → { message: 'Webhook deleted' } and doc gone from list | webhooks.controller.ts:21-50 |
| E-2 | 404s | GET/PATCH/DELETE unknown id → 404 'Webhook not found' | webhooks.service.ts:39-43, 45-48, 50-53 |
| E-3 | Permission matrix | webhook.create/read/update/delete enforced per endpoint (permissions.constants.ts:89-92) | webhooks.controller.ts:22, 28, 34, 40, 46, 53, 59, 66, 73, 79, 86 |
| E-4 | Validation | empty name/url/secret → 400; events: [] → 400 (ArrayMinSize(1)); bad url → 400; enabled non-boolean → 400 | create-webhook.dto.ts:11-34 |
| E-5 | Logs | GET :id/logs returns ≤ 50, newest first; includes payload, status, responseCode?, responseBody? | webhooks.service.ts:88-93, webhook-delivery-log.schema.ts:12-38 |
| E-6 | Metrics | counts reconcile: total = success + failed + pending for same webhook | webhooks.service.ts:149-154 |
| E-7 | Tenant isolation | tenant B cannot read/update/delete/pause tenant A webhooks (scoped filter) | base.repository.ts:20-30 |
2. Delivery pipeline (C)
| # | Check | Expected | Source |
|---|---|---|---|
| D-1 | Event fan-out | emitting event X creates one deliver job per enabled webhook subscribed to X | webhooks.service.ts:55-86, webhook.repository.ts:17-29 |
| D-2 | No fan-out | disabled / soft-deleted / unsubscribed webhooks receive nothing | webhook.repository.ts:23-28 |
| D-3 | Timeout | endpoint that sleeps > 10 s → attempt failed (AbortSignal.timeout(10000)) | webhook-delivery.worker.ts:81 |
| D-4 | Retry counts | failing endpoint observed 3 job attempts with exponential backoff (5 s, 10 s) | webhooks.service.ts:80-83 |
| D-5 | Status recording | 2xx → success with responseCode+body; non-2xx → failed; network error → failed | webhook-delivery.worker.ts:84-99 |
| D-6 | Payload fidelity | receiver gets exactly JSON.stringify(payload) of the emitted event payload | webhook-delivery.worker.ts:70, 80, domain-event.interface.ts:7 |
| D-7 | Queue presence | queue named webhook-deliver registered; worker attached | queue.constants.ts:14, webhooks.module.ts:22, webhook-delivery.worker.ts:9 |
3. Signature verification (C)
| # | Check | Expected | Source |
|---|---|---|---|
| S-1 | Signature correctness | receiver computes HMAC-SHA256(rawBody, secret) hex and matches X-Webhook-Signature | webhook-delivery.worker.ts:77, 103-105 |
| S-2 | Header presence | every POST has Content-Type: application/json, X-Webhook-Signature, X-Webhook-Event | webhook-delivery.worker.ts:76-79 |
| S-3 | Secret mismatch | wrong stored secret → signature mismatch (receiver rejects) - expected, verifies scheme | webhook.schema.ts:19-20 |
| S-4 | Secret rotation | PATCH secret → subsequent deliveries signed with new secret | update-webhook.dto.ts:4, webhooks.service.ts:45-48 |
| S-5 | Known gap (H) | no X-Webhook-Timestamp / replay window - document; verification must not assume one | webhook-delivery.worker.ts:103-105 |
4. Retry & test (H)
| # | Check | Expected | Source |
|---|---|---|---|
| R-1 | Retry no-failure | POST :id/retry with zero failed logs → 404 'No failed deliveries to retry' | webhooks.service.ts:103-108 |
| R-2 | Retry payload | retry re-sends the latest failed attempt's eventType + payload verbatim | webhooks.service.ts:110-118 |
| R-3 | Retry correlation | retried job carries correlationId: '' - receiver must tolerate empty string | webhooks.service.ts:117 |
| R-4 | Test event | POST :id/test → attempt with eventType: 'WebhookTested', payload: { test: true, webhookId } | webhooks.service.ts:130-138 |
| R-5 | Async replies | both endpoints reply { message: '… queued' } before any delivery happens | webhooks.controller.ts:62, 69 |
| R-6 | Double-record bug (H) | on non-2xx the worker records failed twice; second call drops responseCode/responseBody - verify final log has code+body or flag as bug | webhook-delivery.worker.ts:84-99 |
5. Payload size & robustness (M)
| # | Check | Expected | Source |
|---|---|---|---|
| P-1 | Large payload | e.g. 1 MB homework/results payload delivered intact (no truncation) | webhook-delivery.worker.ts:70-82 |
| P-2 | Non-JSON-safe payload | payload containing strings/numbers only (Record<string, unknown>); verify serialization edge cases | webhook-delivery-log.schema.ts:15-16 |
| P-3 | Response body capture | 5xx with HTML body - stored raw, client must render escaped | webhook-delivery.worker.ts:88 |
| P-4 | URL edge cases | require_tld: false allows http://localhost:3000/hook and IPs - keep receiver in same tenant network (SSRF exposure is a security review item, M) | create-webhook.dto.ts:17-18 |
6. Schema/bookkeeping gaps (M)
| # | Check | Expected | Source |
|---|---|---|---|
| G-1 | attemptCount | currently always 0 - assert current behavior, track as bug when worker increments | webhook-delivery-log.schema.ts:31-32 |
| G-2 | lastTriggeredAt / failureCount | never written - surfaces can't sort by activity; track | webhook.schema.ts:25-29 |
| G-3 | Log-per-attempt | retries create new log rows (not updates) - verify UI handles duplicate-ish rows | webhooks.service.ts:62-67, 84-89 |
| G-4 | Free-form events | typo in events silently never fires (exact match, webhook.repository.ts:27) - client picker mitigates; no server whitelist | create-webhook.dto.ts:20-24 |
| G-5 | Secret exposure | plaintext secret in every CRUD response - flag to reviewers; UI must not log it | webhooks.service.ts:31-47 |
7. Resilience (M)
| # | Check | Expected | Source |
|---|---|---|---|
| Q-1 | Redis down at enqueue | queue.add failure surfaces as 500 - webhook creation still persists (fan-out is post-create) | webhooks.service.ts:69-84 |
| Q-2 | Redis down at delivery | jobs stay in BullMQ; worker reconnect replays - verify no duplicate double-record corruption | webhooks.module.ts:22 |
| Q-3 | Idempotency | duplicate event emission produces duplicate deliveries (no dedup) - document for receivers | webhooks.service.ts:55-86 |
| Q-4 | Metrics under load | counts via 4 countDocuments - fine at tenant scale; revisit with pagination (planned) | webhooks.service.ts:149-154 |
8. Manual QA script (smoke)
- Create webhook with 2 events + test receiver (echo endpoint).
- Trigger both events → expect 2 deliveries,
X-Webhook-Eventmatching. - Test ping →
WebhookTestedlog row within seconds. - Point URL at a 500 endpoint → watch 3 attempts →
failed; metrics reconcile. POST :id/retry→ verify original payload resent.- Pause → trigger event → no delivery. Resume → trigger → delivery.
- Delete → absent from list; logs endpoint 404 (webhook deleted).
- Cross-tenant: second tenant cannot see or act on the webhook.
15 - Flutter Implementation Guide (Webhooks Module)
- 1. Folder layout
- 2. Models
- 3. Repositories
- 4. Cubits (see
13for diagrams) - 5. Key implementation details
- 6. Tests
- 7. Analytics (proposed)
- 8. Roadmap items NOT built (flag in code)
Build order and concrete Flutter implementation notes for the Webhooks module client, on top of 00-shared/11 (app architecture) and 00-shared/06 (state). Reminder: per PRD the native app is post-Phase 1 (
PRODUCT_REQUIREMENTS_DOCUMENT.md:144); this guide is the forward-looking build plan.
1. Folder layout
lib/features/webhooks/
data/
models/webhook.dart # webhook.schema.ts:8-30
models/delivery_log.dart # webhook-delivery-log.schema.ts:7-39
repositories/webhook_repository.dart
repositories/delivery_log_repository.dart
domain/
entities/event_types.dart # vocabulary from event-queue-map.ts:6-43
presentation/
cubits/ (WebhookListCubit, WebhookFormCubit, WebhookDetailCubit,
DeliveryLogListCubit, WebhookActionCubit)
screens/ (list, form, detail, logs)
widgets/ (event_picker_chip_field, secret_field, delivery_log_tile,
metrics_tile, signature_verify_card)
2. Models
class Webhook {
final String id, name, url, secret;
final List<String> events;
final bool enabled;
final DateTime? lastTriggeredAt; // unused server-side (14 G-2)
final int failureCount;
// fromJson maps _id, tenantId, isDeleted, version, createdAt, updatedAt
}
class DeliveryLog {
final String id, webhookId, eventType, status; // status ∈ pending|success|failed
final Map<String, dynamic> payload;
final int? responseCode;
final String? responseBody;
final int attemptCount; // always 0 today (14 G-1)
final DateTime attemptedAt, updatedAt;
final DateTime? completedAt;
}
3. Repositories
WebhookRepository:list(),getById(id),create(dto),update(id, dto),delete(id),pause(id),resume(id),test(id),retry(id),metrics(id)- one method per endpoint in
12(no batching; all are single calls).
- one method per endpoint in
DeliveryLogRepository:list(webhookId)→GET /webhooks/:id/logs(≤ 50 rows,webhooks.service.ts:88-93).- Actions return the
{ message }envelope; exposemessagein the cubit state for snackbars ("Test delivery queued",webhooks.controller.ts:69). - 404 on retry → map to
NoFailedDeliveryso the dialog can show "Nothing to retry" (webhooks.service.ts:107-108).
4. Cubits (see 13 for diagrams)
WebhookListCubit- fetch list; optimistic delete with rollback.WebhookFormCubit- single model for create/edit; dirty-field diff on edit; client validation mirroring08§4.WebhookDetailCubit- parallelgetById+metricswith independentLoadStates.DeliveryLogListCubit- refresh after test/retry (2-3 s delay), pull to refresh; exposeisNewestPending.WebhookActionCubit- pause/resume/test/retry; optimisticenabledflip.
5. Key implementation details
- Event picker: statically shipped vocabulary (
event-queue-map.ts:6-43) - no endpoint exists; group by module as in08§3; custom chip for free text (exact match,webhook.repository.ts:27). - Secret field: obscure + generate (client-side
Random.secure(), 32 bytes base64url); never cache to disk (13§7). - Async actions: after test/retry show "queued" snackbar and navigate to
logs; never claim delivery (
12§2 replies). - Status enum: parse
statusstrictly; unknown values → neutral render (webhook-delivery-log.schema.ts:18-23). - Code blocks: payload + responseBody in
SelectableTextmonospace with copy; collapse by default on large payloads (06S6). - Permission gating: RBAC perms
webhook.create/read/update/delete(permissions.constants.ts:89-92) - hide FAB/actions/menus accordingly.
6. Tests
- Unit: model fromJson (incl.
enableddefault), form validation rules, dirty-diff, status parsing. - Cubit: list load/delete rollback; retry 404 →
NoFailedDelivery; metrics partial failure; action messages surfaced. - Widget: event picker toggle + min-1 validation; secret reveal; badge rendering per status.
- Integration (
00-shared/10): against running API - create → test → poll logs untilsuccess; signature verified on an echo receiver.
7. Analytics (proposed)
Wire webhooks.*.* events from 05; no SDK selected yet
(00-shared/10 §8).
8. Roadmap items NOT built (flag in code)
- Logs pagination - wait for server
(planned),webhooks.service.ts:88-93. - Inbound receiver screens - wait for
publicscope(planned),IMPLEMENTATION_PLAN.md:48. - Secret rotation flow
(forward-looking), replay/backfill(planned), event registry endpoint(planned)- all per12§7.
01 — Product Overview (WS / Realtime Module)
The realtime push layer of StudyLyon. Not a product surface by itself — it is the delivery mechanism that makes notifications, dashboards and lists live on web clients. Authoritative conventions live in
00-shared/*; module specifics here.
1. What this module is
StudyLyon's WebSocket stack delivers server-pushed domain events to authenticated clients. Two moving parts in source:
| Piece | Source | Role |
|---|---|---|
WsGateway | src/modules/ws/ws.gateway.ts | Socket.io gateway on namespace /ws; JWT handshake, tenant rooms, subscribe/unsubscribe |
WsBridge | src/modules/ws/ws-bridge.service.ts | Hooks the in-process EventBus and broadcasts every domain event to the emitting tenant |
WsModule | src/modules/ws/ws.module.ts | Module wiring; registers JwtModule for handshake verification (ws.module.ts:7) |
End-to-end flow, per source:
domain service → EventBus.emit() (events/event-bus.service.ts:11)
→ WsBridge.onAny() (ws-bridge.service.ts:16)
→ WsGateway.broadcastToTenant() (ws.gateway.ts:76)
→ server.to(`tenant:{id}`).emit() → all sockets of that tenant
The blueprint defines the scope: "WebSockets under /ws for in-app notifications and
live dashboards" (studylyon-blueprint/02-Architecture/API_DESIGN.md:76).
2. Scope (in / out)
In scope today
- Authenticated connection handshake (JWT access token) —
ws.gateway.ts:35-57. - Implicit tenant room per connection —
client.join('tenant:...')(ws.gateway.ts:50). - Fan-out of all domain events to the tenant room —
ws-bridge.service.ts:16-22. - Client-driven
subscribe/unsubscribeto additional rooms —ws.gateway.ts:63-74.
Out of scope today (see gaps)
- REST surface: none; this module has no HTTP endpoints (see
12_API_Mapping.md). - Per-user rooms, per-room RBAC, event acknowledgement, error channel, heartbeat tuning.
- Redis pub/sub fan-out for horizontal scale — no socket.io adapter exists in
src/infrastructure/redis/(only the sharedREDIS_CLIENT,redis.constants.ts:1-2). - Native mobile push. PRD note: the PRD scopes the product to web clients; the
native app is excluded from realtime scope. Push notifications, device tokens and
session join links are forward-looking (
docs/IMPLEMENTATION_PLAN.md:231,769,859).
Planned consumers (per implementation plan)
- Communication module — realtime message delivery via this
WsModule(docs/IMPLEMENTATION_PLAN.md:119). - Transport live tracking —
docs/IMPLEMENTATION_PLAN.md:229(planned). - Notifications preferences/channels/device tokens —
docs/IMPLEMENTATION_PLAN.md:231(planned; WebSocket is one channel).
3. Goals
- Deliver domain events to the right tenant with low latency (single-hop in-process).
- Keep the connection layer invisible to users (reconnect handled by client + socket.io).
- Stay tenant-isolated: a socket may only receive events for the tenant in its JWT.
- Survive restarts: bridge is idempotent (
onModuleInit,ws-bridge.service.ts:15).
4. Non-goals
- Chat/messaging protocol design (Communication module owns it — planned).
- Guaranteed at-least-once delivery / replay; events are ephemeral fire-and-forget.
- Backfill of missed events while disconnected (clients refetch via REST — eventual consistency).
5. Success signals (proposed)
- Connection success rate ≥ 99.5% on stable networks.
- p95 event → client render < 1 s.
- Zero tenant-isolation incidents.
- 10k concurrent connections per instance load-tested
(
docs/IMPLEMENTATION_PLAN.md:842— plan calls this out as a scale risk).
02 — User Personas (WS / Realtime Module)
- 1. Org Admin — "the overseer"
- 2. Teacher — "the worker"
- 3. Student — "the consumer"
- 4. Parent / Guardian — "the watcher"
- 5. IT / Operations Admin — "the debugger" (proposed)
- Cross-persona patterns
The realtime layer has no screens of its own (except the proposed debug view, 05). Personas describe who benefits from pushed data on existing screens, and how they perceive the connection. All personas are web clients; native app excluded per PRD (see 01).
1. Org Admin — "the overseer"
| Attribute | Detail |
|---|---|
| Profile | Principal / owner; monitors school-wide operations from the dashboard |
| Realtime needs | Live fee-collection totals, admission pipeline changes, attendance % movement; wants the dashboard to update without refresh |
| Connection context | Desktop web, stable office network; often has the dashboard open for hours |
| Failure tolerance | Low patience for stale numbers — if the socket drops, they expect a visible offline indicator and auto-recovery |
| Priority events | payment.completed, crm.*, attendance.* (dot-namespace per API_STANDARDS.md:642) |
| Quote | "If the number is wrong I reload the page — please just tell me when it's stale." |
2. Teacher — "the worker"
| Attribute | Detail |
|---|---|
| Profile | Takes attendance, posts homework, handles leave requests |
| Realtime needs | New message / announcement toasts while working; live refresh of lists after colleagues submit |
| Connection context | Mixed: school wifi, sometimes a classroom AP with NAT; short sessions between classes |
| Failure tolerance | High for disconnects (they close laptops), low for missed messages — mis-delivery is worse than no delivery |
| Priority events | notification.created, communication.* (planned), leave.* (planned) |
| Quote | "Tell me once, in the corner. Don't make me hunt for it." |
3. Student — "the consumer"
| Attribute | Detail |
|---|---|
| Profile | Receives announcements, homework, results; watches live class/DPP content later (forward-looking) |
| Realtime needs | Instant announcements; homework-published nudge; result-published nudge |
| Connection context | Phone browser on school wifi / mobile data; tab often backgrounded → throttled |
| Failure tolerance | High; background tabs lose the socket — badge must be reconciled via REST on resume |
| Priority events | homework.published, notification.created, examination.* (planned) |
| Quote | "If the app was closed, at least show me the badge when I open it." |
4. Parent / Guardian — "the watcher"
| Profile | Receives fee receipts, attendance alerts, transport status (planned) |
| Realtime needs | Alerts only — no dashboard residency; expects reliability of delivery, not of live updates |
| Priority events | payment.*, attendance.updated alerts, transport.* (planned) |
| Failure tolerance | Lowest of all — an undelivered alert is an incident. REST refetch on resume is mandatory |
5. IT / Operations Admin — "the debugger" (proposed)
| Profile | Owns connectivity: kiosk displays, CCTV-adjacent dashboards, school lab terminals |
| Realtime needs | See connection state, event stream, fan-out correctness — a realtime debug view (proposed) (05 §5) |
| Priority events | Any; wants the event log, not the domain semantics |
Cross-persona patterns
- Passive consumers: nobody sends realtime messages today; the only client→server
traffic is
subscribe/unsubscribe(ws.gateway.ts:63-74). The API must stay this way until the Communication module defines its protocol(planned). - Multi-tab reality: every persona may have 2+ tabs open → one socket per tab; the
server broadcasts to all sockets in the tenant room (
ws.gateway.ts:50,76-78), so clients must deduplicate (see 10_Interaction_Specification.md). - Roles exist on the socket (
client.roles,ws.gateway.ts:17,48) but are not yet enforced for room access — see gap in 12_API_Mapping.md.
03 — User Journey (WS / Realtime Module)
- J1 — Login to connected (all personas)
- J2 — Live dashboard update (Org Admin)
- J3 — Notification receipt (Teacher / Student / Parent)
- J4 — Network flap (all personas)
- J5 — Message delivery (planned)
- J6 — Realtime admin debug (proposed)
- Journey rules
Journeys across the connection lifecycle, not screens — the realtime layer's only native moments are connect, event, and reconnect. Source-grounded: handshake (
ws.gateway.ts:35-57), fan-out (ws-bridge.service.ts:16-22), rooms (ws.gateway.ts:50,63-74).
J1 — Login to connected (all personas)
| Step | Actor | System (source) |
|---|---|---|
| 1 | User signs in; REST returns access token | Auth module (token subject/tenant/roles payload) |
| 2 | Client opens socket to /ws with token | namespace: '/ws' (ws.gateway.ts:20-23); token via handshake.auth.token or handshake.query.token (ws.gateway.ts:37-39) |
| 3 | Server verifies token against JWT_ACCESS_SECRET | ws.gateway.ts:42-44 |
| 4 | Server joins socket to tenant:{tenantId} room | ws.gateway.ts:50 |
| 5 | Client shows connected indicator; subscribes to extra rooms if needed | subscribe event (ws.gateway.ts:63-68) |
| 6 | Server begins delivering tenant events | bridge active (ws-bridge.service.ts:23) |
Failure paths: missing token → UnauthorizedException → client.disconnect()
(ws.gateway.ts:40,54-56). Invalid/expired token → same disconnect. The client must
re-drive this journey after any reconnect.
J2 — Live dashboard update (Org Admin)
- Admin has dashboard open on a fee page.
- Cashier marks payment complete →
payments.*domain event emitted (EventBus.emit,events/event-bus.service.ts:11). WsBridge.onAnycatches it (ws-bridge.service.ts:16) →broadcastToTenant(ws.gateway.ts:76) → all admin sockets intenant:{id}.- Client receives envelope
{eventType, occurredAt, payload}(ws-bridge.service.ts:17-21). - Dashboard tile animates the new total without a page reload.
J3 — Notification receipt (Teacher / Student / Parent)
- User is on any module screen.
- Announcement published →
notification.createdevent (dot-namespace convention,API_STANDARDS.md:642-658). - Toast slides in (
m-fast, 00-shared/08), bell badge increments. - User taps → navigates to the detail; list shows the item (reconciled with REST).
J4 — Network flap (all personas)
- WiFi drops. Socket.io client detects ping timeout (server-side heartbeat defaults).
- Client enters reconnecting state; UI shows offline banner (component in 07).
- Backoff retries re-establish the connection (client-side; see 15).
- On reconnect: handshake runs again — token must still be valid (see 14_QA_Checklist.md "auth on reconnect"). If the access token expired, the client refreshes via REST and reopens the socket.
- Missed events are not replayed (fire-and-forget bridge). Client refetches affected lists via REST for eventual consistency — mandatory reconciliation rule (10 §6).
J5 — Message delivery (planned)
- Teacher sends message → Communication module
(planned), delivered via thisWsModule(docs/IMPLEMENTATION_PLAN.md:119). - Recipient sockets receive the message event; thread list updates live.
- Until Communication lands, this journey is a contract placeholder, not a feature.
J6 — Realtime admin debug (proposed)
- IT admin opens Realtime Debug view
(proposed)— 05 §5. - Sees connection health, live event stream, room membership for own tenant.
- Uses it to verify fan-out after a deployment.
Journey rules
- Every reconnect restarts at step 2 of J1 — there is no session resumption in source.
- No journey requires the user to act on the connection itself; failure UX is banner-level (see 06 §1) and non-blocking.
04 — Information Architecture (WS / Realtime Module)
- 1. Transport
- 2. Identity model
- 3. Room model
- 4. Event taxonomy
- 5. Consumer mapping (web UI)
- 6. Naming conventions for the web client
The transport contract of the realtime layer: namespaces, rooms, envelope, naming. Mirrors 00-shared/05 at the wire level; module facts here are all source-grounded.
1. Transport
| Aspect | Value | Source |
|---|---|---|
| Protocol | Socket.io over WebSocket (engine.io fallback transports) | ws.gateway.ts:10-11 |
| Namespace | /ws | ws.gateway.ts:21 |
| CORS | origin: '*', credentials: true | ws.gateway.ts:22 |
| Payload wire format | JSON (socket.io default) | — |
| Heartbeat | socket.io defaults (pingInterval 25 s / pingTimeout 20 s) — not tuned in source | gap → 14_QA_Checklist.md |
2. Identity model
A connected socket carries (source ws.gateway.ts:14-18,46-48):
AuthenticatedSocket { userId, tenantId, roles[] } ← from verified JWT (payload.sub, tenantId, roles)
- Tenant scope is fixed at connect time by the JWT (
ws.gateway.ts:47); it is never supplied by the client on the wire. This is the security boundary of the whole module. roles[]are carried but unused in routing today (gap; see12).
3. Room model
| Room | Created | Who joins | Purpose | Source |
|---|---|---|---|---|
tenant:{tenantId} | implicit, at connect | every socket of the tenant | tenant-wide fan-out of domain events | ws.gateway.ts:50 |
| arbitrary room string | on subscribe | any authenticated socket | entity-level delivery (e.g. class:10-A, user:{id}) | ws.gateway.ts:63-68 |
Rules:
subscribe/unsubscribeaccept any string (type-checked only:typeof room !== 'string'returns silently —ws.gateway.ts:65,71). No allow-list, no RBAC check → gap (see12).- Rooms are per-connection and die with the socket; clients must re-
subscribeafter every reconnect (13_State_Management.md). - There is no per-user room in source; user-scoped delivery today = tenant room +
client-side filter, or an explicit
subscribeto a convention likeuser:{id}(planned convention, not enforced).
4. Event taxonomy
Server→client events are domain event types relayed by the bridge, wrapped in an
envelope (ws-bridge.service.ts:17-21):
{
"eventType": "notification.created",
"occurredAt": "2026-08-03T09:00:00.000Z",
"payload": { }
}
- Names use dot notation — blueprint convention:
attendance.updated,payment.completed,notification.created,homework.published(API_STANDARDS.md:642-658). - The underlying
DomainEventalso carriesactorIdandcorrelationId(events/domain-event.interface.ts:1-8) — not forwarded by the bridge today. - Client→server events in source: exactly
subscribeandunsubscribe(ws.gateway.ts:63-74).
5. Consumer mapping (web UI)
| Domain event family (examples from blueprint §28) | UI consumer | Status |
|---|---|---|
notification.created | bell badge, toast | convention — modules to emit (planned) |
payment.completed | fee dashboard tiles | planned |
attendance.updated | attendance lists/dashboard | planned |
homework.published | homework lists | planned |
communication.* | message threads | planned (IMPLEMENTATION_PLAN.md:119) |
transport.* | live tracking | planned (IMPLEMENTATION_PLAN.md:229) |
| job progress | async-op status (API_STANDARDS.md:579) | planned |
6. Naming conventions for the web client
- Channel constants in one file (
realtime_channels.dart) — mirror server room strings. - Event constants mirror server eventType strings 1:1 (no client-side renaming).
- Envelope is the only accepted server→client shape; anything else is a protocol violation.
05 — Screen Inventory (WS / Realtime Module)
- 1. Global Connection Status Indicator (on every authenticated screen)
- 2. Notification Bell + Toast (on every authenticated screen)
- 3. Live-updating lists & dashboard tiles (module screens)
- 4. Connection Status Detail Sheet
(proposed) - 5. Realtime Admin Debug View
/admin/realtime(proposed) - Component usage summary
- Keyboard / landscape / tablet
The realtime layer has no standalone screens — it manifests as connection state and live-update behavior on existing screens. This inventory is UI-context only: it enumerates every surface where realtime is visible, plus the one proposed debug screen. Authoritative components in 00-shared/03; this file names which each surface uses.
Legend — States: idle / connecting / connected / reconnecting / offline(disconnected) / error(auth) / stale-data. Analytics:
{module}.{screen}.{action}(proposed; SDK open — 00-shared/10 §8).
1. Global Connection Status Indicator (on every authenticated screen)
| Field | Detail |
|---|---|
| Purpose | Show whether live updates are flowing; never block work |
| Location | Top app bar trailing slot / app-level banner region (below app bar on mobile) |
| Entry | auto with app shell; state from socket lifecycle |
| Exit | none (persistent); tap → status sheet (§4) on desktop |
| Components | AppRealtimeStatusDot, AppConnectionBanner (offline only), AppSnackbar |
| States | connected (dot, tooltip "Live"), connecting, reconnecting (pulsing dot), offline (banner + dot), error-auth (banner: "Session expired — reconnecting") |
| Analytics | app.ws.{connect,reconnect,offline,error} (proposed) |
| a11y | status announced via live region on state change; dot carries semantic label, never color-only |
| Motion | m-fast fade/pulse (00-shared/08) |
2. Notification Bell + Toast (on every authenticated screen)
| Field | Detail |
|---|---|
| Purpose | Surface notification.created without navigating |
| Entry | app shell |
| Behavior | bell badge increments on event; toast (snackbar) shows for high-priority types only |
| Exit | tap bell → notifications list (REST); tap toast → target route |
| Components | AppBadge, AppSnackbar, AppListTile |
| States | idle, toast-expanded, badge-count overflow (99+) |
| Analytics | notif.received.{type}, notif.tap (proposed) |
3. Live-updating lists & dashboard tiles (module screens)
| Field | Detail |
|---|---|
| Purpose | Refresh list rows / KPI tiles in place when a matching event arrives |
| Host screens | Dashboard overview, Fees, Attendance, Homework, Results, CRM (per module design docs) |
| Pattern | AppLiveList wrapper (component in 07); event → update row by id, else schedule REST refetch |
| States | idle, live-updating (row flash m-fast), stale (offline banner, AppSkeleton for loading) |
| Rule | never mutate a list while the user is editing within it (see 10 §2) |
4. Connection Status Detail Sheet (proposed)
| Field | Detail |
|---|---|
| Purpose | Show user-facing diagnostics: state, server time delta, last event received |
| Entry | tap status dot (desktop) / long-press (mobile) |
| Content | state chip, "last event: {eventType} {occurredAt}", "reconnect attempt #n", settings shortcut |
| Components | AppBottomSheet, AppListTile, AppChips |
| Analytics | app.ws.status_open (proposed) |
5. Realtime Admin Debug View /admin/realtime (proposed)
| Field | Detail |
|---|---|
| Purpose | IT/admin verification: see the event stream and connection health for the tenant |
| Route | /admin/realtime (admin-gated; rbac perms — none exist yet for ws, see 12) |
| Content | live event log (eventType, occurredAt, payload size), socket count, room list, fan-out errors; clear/pause controls (client-side pause only) |
| Components | AppEventLog, AppDataTable, AppSegmentedButton |
| States | live, paused, empty ("waiting for events…"), filtered by eventType |
| Analytics | admin.ws.{open,pause,filter} (proposed) |
| Note | Server-side counterpart (per-socket visibility) is forward-looking |
Component usage summary
AppRealtimeStatusDot, AppConnectionBanner, AppLiveList, AppEventLog,
AppBadge, AppSnackbar, AppBottomSheet, AppListTile, AppSkeleton,
AppEmptyState. Module-specific: AppRealtimeStatusDot and AppLiveList are
defined in 07_Component_Library.md.
Keyboard / landscape / tablet
- Status dot + banner are fixed-position surfaces: safe-area inset aware, never overlap scroll content; landscape keeps banner ≤ 1 line.
- Debug view: tablet/desktop two-column (log + metrics); mobile single column.
06 — Screen Specifications (WS / Realtime Module)
- §1 Connection Status Indicator
- §2 Notification Bell + Toast
- §3 Live-updating Lists & Dashboard Tiles
- §4 Connection Status Detail Sheet
(proposed) - §5 Realtime Admin Debug View
/admin/realtime(proposed) - §6 Cross-cutting specifications
Detailed specification of every realtime surface from 05. All behavior is tied to source facts where they exist; client-side behavior not in source is marked "(proposed)" or "(planned)". This is the largest spec of the module — it is the contract the Flutter/web client must implement.
§1 Connection Status Indicator
1.1 Placement & anatomy
- Top app bar trailing slot on mobile; app-bar region on desktop; offline state promotes
to a full-width
AppConnectionBannerunder the app bar (see 00-shared/03 for primitives). - Contains: status dot (8 dp) + optional label "Live" / "Offline — retrying…".
1.2 State machine (client)
| State | Trigger | Visual | Behavior |
|---|---|---|---|
idle | app start, before first socket open | dot grey | nothing |
connecting | socket open initiated | dot amber, pulsing | no banner |
connected | handshake OK — server joined tenant room (ws.gateway.ts:50) | dot green | tooltip "Live"; banner hidden |
reconnecting | socket.io reconnect attempt | dot amber pulse | banner only after 1st failed attempt |
offline | heartbeat loss / transport error, retries exhausted | dot red + banner "Live updates paused" | manual "Retry now" affordance + auto backoff continues |
error-auth | handshake rejected → server client.disconnect() (ws.gateway.ts:54-56) | dot red + banner "Session expired" | client refreshes access token via REST, reopens socket; if refresh fails → sign-out flow |
1.3 Rules
- Status is global (one app-level state), never per-screen.
- No modal ever blocks on connection state; the layer is passive (see 09).
- Stale-data rule: while
offline/reconnecting, any screen showing live-fed values renders the offline banner; values are not cleared (graceful degradation). - a11y: every state change announces via
SemanticsService.liveRegion; dot never color-only (00-shared/09).
1.4 Source grounding
- Auth failure path: missing token →
UnauthorizedException('Missing token'), catch →client.disconnect()(ws.gateway.ts:37-40,54-56). - No server session resumption: reconnect = full re-handshake (see 03 J4).
§2 Notification Bell + Toast
2.1 Bell badge
- Badge counts
notification.createdevents since last list open (client-side count). - Cap display at
99+. - On bell open → fetch list via REST (authoritative) and reset count — events are
ephemeral; the list is source of truth (bridge is fire-and-forget,
ws-bridge.service.ts:16-22).
2.2 Toast
- Shown for notification types marked high-priority (module config,
(planned)— the notifications module owns priority semantics,IMPLEMENTATION_PLAN.md:231). - One toast at a time; queue others; auto-dismiss 5 s; tap → navigate to target route.
- While the user is typing/editing: toast never steals focus (see 10 §2).
2.3 Edge cases
| Case | Behavior |
|---|---|
| Duplicate event (multi-tab, 02 §cross-persona) | dedupe by payload.{entityId}+eventType within a short window; single toast |
| Event for soft-deleted entity | toast still shows; tap → REST 404 → snackbar "No longer available" |
| Arrived while offline | not delivered (no replay); bell reconciled on REST fetch |
| Malformed envelope | ignore + log client-side (defensive; see 14 §Q8) |
§3 Live-updating Lists & Dashboard Tiles
3.1 The AppLiveList contract
Applies to: Dashboard overview, Fees, Attendance, Homework, Results, CRM lists.
- Screen subscribes (via
RealtimeClientstream, 13) to the event families it renders. - On event: match
payload.entityIdagainst loaded rows.- Hit → update that row in place (
m-fasthighlight flash). - Miss (not loaded or filtered out) → decide by screen rule: if the entity belongs to the current filter, insert; else ignore.
- Hit → update that row in place (
- If a screen cannot map the payload to a row (schema drift), it schedules one REST refetch of the current page (bounded: 1 refetch / 5 s / screen).
- Sorting/aggregation (e.g. dashboard totals) → recompute locally, do not refetch.
- Offline state: keep last known data, banner on; on reconnect → one refetch per visible live screen (eventual consistency, 03 J4 step 5).
3.2 Per-screen event mapping (proposed defaults; align with each module doc)
| Screen | Events to render | Action |
|---|---|---|
| Dashboard — fees tile | payment.completed | increment collected total |
| Dashboard — attendance tile | attendance.updated | refresh today's % |
| Fees — invoice list | payment.* | row status update |
| Attendance — today list | attendance.updated | update rows by studentId |
| Homework — list | homework.published | insert row at top |
| CRM — leads | crm.lead.* | row/status update |
3.3 Rules
- Live events never replace the primary load; they mutate an existing snapshot.
- Never write user-visible "confetti" or full-screen overlays on events — 09 keeps the layer passive.
- Pull-to-refresh stays available and forces a REST refetch regardless of socket state.
§4 Connection Status Detail Sheet (proposed)
| Field | Spec |
|---|---|
| Entry | tap status dot (desktop) / long-press dot (mobile) |
| Content | state chip; "last event: {eventType} at {occurredAt}" (from last received envelope, ws-bridge.service.ts:17-21); reconnect attempt count; server clock delta |
| Actions | "Retry now" (if offline), "Open debug view" (admin only, §5) |
| Empty | — |
| Motion | sheet slides m-base; content fades in m-fast |
| a11y | sheet is a dialog region; focus first action |
§5 Realtime Admin Debug View /admin/realtime (proposed)
5.1 Layout
- Header: connection state + socket count for this tenant (server-provided — see note), pause/resume toggle, eventType filter chips.
- Body:
AppEventLog— monospace rows:time | eventType | payload.size | entityId. - Footer: session token expiry countdown (client-side check against
JWT_ACCESS_SECRETexpiry from the auth store).
5.2 Behavior
- Log buffers last 500 events client-side; pause stops render (not capture).
- Filter by eventType substring; clear button.
- Row tap → payload pretty-printed in a bottom sheet.
5.3 Notes
- Client-side only today: it renders what the socket receives. Server-side metrics
(per-connection views, room members) are forward-looking — the gateway logs
connect/disconnect only (
ws.gateway.ts:51-53,59-61). - Gate with admin role: reuse existing RBAC admin permission (
rbacmodule); nows.*permission exists yet (permissions.constants.ts:1-97— verified absent, see 12).
§6 Cross-cutting specifications
| Topic | Spec |
|---|---|
| Event ordering | socket.io preserves per-connection order; across reconnects there is no ordering guarantee — reconcile with REST |
| Envelope validation | client must validate {eventType: string, occurredAt: string, payload: object}; reject anything else (ws-bridge.service.ts:17-21) |
| Token refresh mid-session | before reconnect, check access-token expiry; refresh via REST (/auth/refresh) then open socket (source: handshake requires valid token, ws.gateway.ts:42-44) |
| Rooms to re-join on reconnect | tenant room is automatic (ws.gateway.ts:50); extra rooms must be re-subscribed by client (ws.gateway.ts:63-68) |
| Multi-tab | one socket per tab; dedupe events by envelope (eventType + correlationId — note correlationId is NOT forwarded; use entityId until server forwards it) |
| Background tab | socket stays; if OS suspends, reconnect flow handles it; badge reconciled on resume |
| Debug/data | never log payload contents; log eventType + occurredAt only (10 §8) |
07 — Component Library (WS / Realtime Module)
- 1.
AppRealtimeStatusDot - 2.
AppConnectionBanner - 3.
AppLiveList - 4.
AppNotificationToast - 5.
AppEventLog(debug, proposed) - 6.
RealtimeClient(non-visual core) - Composition rules
Components the realtime layer introduces. Base primitives come from 00-shared/03; these are module-level compositions with the wire contract baked in. Shared design-system mapping lives in 11 and 00-shared/04.
1. AppRealtimeStatusDot
| Field | Detail |
|---|---|
| Purpose | One-glance connection state, non-interactive by default |
| Props | state: RealtimeState (idle/connecting/connected/reconnecting/offline/error-auth) |
| Anatomy | 8 dp circle + optional 12 dp label |
| Colors | from 00-shared/02 status tokens (success/amber-warning/danger/neutral) |
| Behavior | pulse animation while connecting/reconnecting (m-fast); tooltip "Live" when connected |
| a11y | semantic label from state text; never color-only (00-shared/09) |
| Wire-up | consumes RealtimeBloc state (13); no direct socket access |
2. AppConnectionBanner
| Field | Detail |
|---|---|
| Purpose | Non-blocking offline/error surface under the app bar |
| Props | state (offline / error-auth), onRetry (optional) |
| Anatomy | 1-line banner: icon + message + optional "Retry" text button |
| States | offline: "Live updates paused"; error-auth: "Session expired — reconnecting" |
| Motion | slide-in m-fast; never blocks scroll content (fixed slot) |
| a11y | live-region announcement on show/hide |
| Rules | shown only in offline/error-auth; hidden otherwise (no persistent banner) |
3. AppLiveList
| Field | Detail |
|---|---|
| Purpose | Wrapper that merges socket events into an existing paginated list (06 §3.1) |
| Props | items, eventStream (filtered), entityIdOf(item), onRowEvent(item, event), refetch |
| Behavior | row-level in-place update + flash; miss → optional insert or bounded refetch; sort/aggregate locally |
| Limits | bounded refetch: 1 / 5 s / screen; never mutates during user edit (10 §2) |
| Reuse | wraps 00-shared/03 list + skeleton primitives |
| Testing | pure Dart logic (match/insert/refetch) — unit-testable without socket |
4. AppNotificationToast
| Field | Detail |
|---|---|
| Purpose | Transient high-priority notification surface |
| Props | event (envelope), onTap |
| Anatomy | AppSnackbar-based: icon + title + entity snippet; queueing; 5 s auto-dismiss |
| Dedup | by eventType+entityId within 30 s window (multi-tab, 02) |
| Rules | never steals focus; max 1 visible |
5. AppEventLog (debug, proposed)
| Field | Detail |
|---|---|
| Purpose | Monospace stream of received envelopes for the admin debug view (06 §5) |
| Props | events (last 500), filter |
| Anatomy | rows: `HH:mm:ss.SSS |
| Behavior | pause render (not capture); clear; substring filter |
| Rules | debug-only; guarded by admin route gate |
6. RealtimeClient (non-visual core)
| Field | Detail |
|---|---|
| Purpose | Owns the socket lifecycle, handshake, room membership, event stream (13) |
| API | connect(token), disconnect(), subscribe(room), unsubscribe(room), Stream<WsEnvelope> events, RealtimeState state |
| Contract | handshake token → auth.token or query.token (ws.gateway.ts:37-39); subscribe message = room string (ws.gateway.ts:63-68) |
| Security | token from secure store only; never logged (15 §8) |
| Reuse | one instance per app run (singleton scope), injected via DI |
Composition rules
- Visual components never import the socket library; they consume
RealtimeClient/RealtimeBlocstreams only. AppLiveListis the only list component aware of events; plain lists stay REST-only.- All components work with zero server changes — they render the existing bridge
envelope (
ws-bridge.service.ts:17-21).
08 — Form Specifications (WS / Realtime Module)
- 1. Handshake inputs (not a form, but a contract)
- 2. Client→server messages
- 3. What the client must validate (proposed)
- 4. Server→client "responses" (informational, not form errors)
- 5. Future wire inputs (planned)
The realtime module has no forms — but it has wire inputs. This file specifies the only data the client ever sends, the handshake parameters, and their validation, because they are the module's equivalent of a form contract. General form/validation conventions: 00-shared/07 (API conventions) and 00-shared/03 (form primitives).
1. Handshake inputs (not a form, but a contract)
| Input | Where | Rules | Source |
|---|---|---|---|
| Access token | handshake.auth.token or handshake.query.token | required; JWT verified against JWT_ACCESS_SECRET; missing → UnauthorizedException('Missing token'); any verify failure → socket closed | ws.gateway.ts:37-44,54-56 |
| Namespace | URL path /ws | fixed | ws.gateway.ts:20-23 |
Client rules:
- Prefer
auth.token(not in URL/logs);query.tokenexists for non-engine.io clients. - Token must be the same family as REST (
JWT_ACCESS_SECRET), so refresh flow is shared. - No other handshake fields (no tenantId — server takes it from the token,
ws.gateway.ts:47).
2. Client→server messages
| Event | Payload | Validation (source) | Effect |
|---|---|---|---|
subscribe | room: string | typeof room !== 'string' → silently ignored (ws.gateway.ts:65) | join room → future broadcastToRoom delivery (ws.gateway.ts:80-82) |
unsubscribe | room: string | typeof room !== 'string' → silently ignored (ws.gateway.ts:71) | leave room |
Semantics:
- Idempotent: subscribing twice is safe (socket.io joins are idempotent).
- No acknowledgment message is sent (server does not reply — no ack/error channel in
source; verified: gateway handlers return
void). - No validation of room name content: any string accepted → gap (allow-list + RBAC recommended, 12 §5).
3. What the client must validate (proposed)
roomlength ≤ 128; only[a-z0-9:_-]charset enforced client-side to avoid injecting reserved namespaces; server-side enforcement is the real fix (gap).- Never subscribe to
tenant:{...}explicitly (automatic — duplicate is harmless).
4. Server→client "responses" (informational, not form errors)
| Situation | Server behavior | Client handling |
|---|---|---|
| Bad/missing token | close connection (no error event) | state error-auth → refresh token → reconnect (06 §1.2) |
| Successful connect | joins tenant:{tenantId} (ws.gateway.ts:50) | state connected |
| Event delivery | envelope {eventType, occurredAt, payload} (ws-bridge.service.ts:17-21) | validate envelope, route to blocs (13) |
| Unknown client event name | ignored (no handler registered) | n/a (client only sends subscribe/unsubscribe) |
5. Future wire inputs (planned)
- Communication module will define its own client→server messages (send/typing/read
receipts)
(planned)—docs/IMPLEMENTATION_PLAN.md:119. Until then the client MUST NOT send free-form messages. - Event-subscription filters (per-eventType)
(planned); today a client receives the whole tenant stream and filters locally (06 §6).
09 — User Behaviour (WS / Realtime Module)
- 1. Users are passive consumers
- 2. Tolerance windows
- 3. Notification consumption
- 4. Editing vs. live mutation conflict
- 5. Admin/debug behaviour (proposed)
- 6. Multi-device reality
- 7. Connection-affecting behaviours
- 8. Trust rules
How users actually interact with a passive realtime layer — and the behavioural rules the client must encode so the module stays invisible until it must be visible. Personas: 02; journeys: 03.
1. Users are passive consumers
- The only explicit user actions tied to realtime: pull-to-refresh (reconciliation),
tapping a toast, opening the status sheet
(proposed), and — offline — tapping "Retry now". Everything else is ambient. - Consequence: no screen may be designed around realtime being present. Every surface must be fully functional with the socket dead (REST fallback). Realtime is an enhancement layer, never a dependency for rendering.
2. Tolerance windows
| Behaviour | Observed pattern | Rule |
|---|---|---|
| Brief reconnect (≤ 5 s) | users don't notice | no banner needed until first retry fails |
| Sustained offline (> 10 s) | users expect an indicator and a path to fix | banner + status sheet |
| Stale numbers | users distrust the screen and reload | offline banner must say updates are paused (06 §1.3) |
| Missed event while offline | users notice only when list looks wrong | reconcile on reconnect (06 §3.1) |
3. Notification consumption
- Users acknowledge toasts within seconds or ignore them entirely; bell badge is the durable signal. Badge reset on bell-open is mandatory (not on toast display).
- Duplicate toasts (multi-tab) are actively confusing — dedupe is a correctness rule (06 §2.3), not polish.
4. Editing vs. live mutation conflict
- A user mid-edit (typing a fee entry, editing a mark) reacts badly to rows re-ordering
or inputs changing underneath them.
AppLiveListmust defer mutation while the screen has an active edit session (10 §2). - Users do not expect their in-progress edits to be merged with live data — deferring the event-driven update until the edit ends is acceptable and expected.
5. Admin/debug behaviour (proposed)
- IT admins open the debug view after deploys and during incident reports; they expect a pauseable stream and filter, not a wall of text. They will screenshot it — payloads must be redactable (no PII in payload render, 10 §8).
6. Multi-device reality
- Same user, two tabs / two devices: both receive tenant broadcasts
(
ws.gateway.ts:50,76-78). Users accept this; they do not accept double toasts — dedupe again. They also expect the last-activity device to win for write conflicts — server-side REST semantics already handle that; realtime never performs writes.
7. Connection-affecting behaviours
- Locking the workstation / closing the laptop → heartbeat timeout → reconnect cycle; users expect zero-state loss on return (state restored, one refetch).
- Background tabs: browsers throttle timers; socket.io ping may stretch — clients should not treat slow pings as failure (use the server's disconnect as the signal, 03 J4).
- Corporate proxies that block WS: engine.io falls back to long-polling (socket.io default transports) — the client must keep functioning in degraded transport.
8. Trust rules
- Never claim "Live" when state ≠ connected — status must be truthful to the millisecond (06 §1).
- On
error-auth, do not loop forever: bounded retries → sign-out guidance (06 §1.2, 14 Q2).
10 — Interaction Specification (WS / Realtime Module)
- 1. Connection lifecycle interactions
- 2. Live-update interactions
- 3. Toast queueing rules
- 4. Navigation from events
- 5. Ordering & dedup semantics
- 6. Offline / reconnect interactions
- 7. Keyboard / adaptive interactions
- 8. Analytics events (proposed)
Precise interaction rules for the realtime layer. Motion/animation tokens come from 00-shared/08; accessibility baseline from 00-shared/09. Analytics
(proposed).
1. Connection lifecycle interactions
| Trigger | Interaction |
|---|---|
| App start | silent connecting; no UI until connected or first failure |
| Connected | nothing visible (dot green, no animation) |
| Reconnect attempt #1..n | pulsing dot only; after n=1 show banner (06 §1.2) |
| Offline (> 30 s) | banner + "Retry now"; background backoff continues |
| error-auth | banner "Session expired — reconnecting"; client refreshes token then reconnects; on refresh failure → app sign-out with snackbar |
| Manual retry | cancels backoff timer, immediate connect attempt, m-fast state change |
All transitions announce via live region (00-shared/09); none block user input.
2. Live-update interactions
| Rule | Detail |
|---|---|
| Non-interruption | events never steal focus, move the caret, or reorder mid-edit — defer via AppLiveList (06 §3, 09 §4) |
| Row flash | matching row gets a 400 ms highlight (m-fast ease-out) then settles |
| Insert | only at list head or by current sort; never while pull-to-refresh is active |
| Aggregation | dashboard tiles animate delta (m-fast); total recomputed locally, no refetch |
| Pull-to-refresh | always available; forces REST refetch even when connected (source of truth) |
| Toast tap | navigate to target; bell badge decrements only via list-open reset (09 §3) |
3. Toast queueing rules
- Max 1 visible toast; others queue FIFO; queue cap 5 (drop oldest, keep badge).
- High-priority types only (06 §2.2); all events still increment the badge.
- Dedup window 30 s per
eventType+entityId.
4. Navigation from events
- Envelope carries only
{eventType, occurredAt, payload}(ws-bridge.service.ts:17-21); navigation targets are derived fromeventTypevia a static route map (notification.created→ notification detail, etc.)(proposed). - If target entity is gone (REST 404), snackbar "No longer available"; never a dead screen.
5. Ordering & dedup semantics
- In-connection order is guaranteed by socket.io; cross-reconnect order is not — after reconnect, reconcile once per visible live screen (06 §3.1).
- Dedup key:
eventType + entityId(payload). Correlation ID exists on the domain event (events/domain-event.interface.ts:6) but is not forwarded by the bridge — until it is, entityId is the best key (see 12 gap). - Server event rate: bridge forwards every domain event for the tenant
(
ws-bridge.service.ts:16-22) — clients must filter client-side today (08 §5).
6. Offline / reconnect interactions
- On
offline: pause live mutations; keep data; show banner. - On
connected(reconnect): re-subscribeextra rooms (ws.gateway.ts:63-68— rooms die with the socket), then one refetch per visible live screen. - No replay of missed events — accept eventual consistency (fire-and-forget bridge,
ws-bridge.service.ts:16-22).
7. Keyboard / adaptive interactions
- Banner "Retry" is focusable and Enter-activatable; status dot is a button (focusable)
opening the status sheet
(proposed). - Debug view: filter field keyboard enter applies filter; Escape clears.
- Desktop hover on status dot shows tooltip; mobile long-press opens sheet (05 §4).
8. Analytics events (proposed)
app.ws.connect, app.ws.disconnect, app.ws.reconnect{attempt}, app.ws.offline{ms},
app.ws.auth_error, app.ws.event_received{eventType} (sampled ≤ 1%), app.ws.toast_tap,
app.ws.status_open, admin.ws.{open,pause,filter}.
Logging rule: never log payload contents or tokens; eventType + occurredAt + sizes only (06 §6, 07 §6).
11 — Design System Mapping (WS / Realtime Module)
- 1. Token mapping
- 2. Component mapping
- 3. State mapping
- 4. Typography
- 5. Motion (from 00-shared/08)
- 6. Accessibility (from 00-shared/09)
- 7. QA alignment (from 00-shared/10)
Maps every realtime component/surface to the shared design system. Sources of truth: 00-shared/02 (Design Tokens), 00-shared/03 (Component Library), 00-shared/04 (Design System Mapping), 00-shared/08 (Interaction & Motion), 00-shared/09 (Accessibility Baseline), 00-shared/10 (QA Baseline).
1. Token mapping
| Real-time concept | Token / primitive | Notes |
|---|---|---|
| connected | color.success (status) | from 00-shared/02 status palette |
| connecting / reconnecting | color.warning + pulse | pulse duration from 00-shared/08 motion tokens |
| offline / error-auth | color.danger | banner uses surface.danger-container for background |
| idle (neutral) | color.neutral | dot only, no label |
| status dot | 8 dp circle, radius.round | from shared shape tokens |
| banner | AppBanner/AppOfflineBanner primitive (00-shared/03) | module composes AppConnectionBanner (07 §2) |
| row flash | m-fast (≤ 200 ms) ease-out highlight | from 00-shared/08 |
| toast | AppSnackbar | from 00-shared/03 |
| live-region announcements | a11y baseline §3 | 00-shared/09 |
2. Component mapping
| ws component (07) | Built on (shared) | Extends with |
|---|---|---|
AppRealtimeStatusDot | AppBadge/dot primitive + tooltip | state→color/motion mapping table |
AppConnectionBanner | AppBanner/AppOfflineBanner | retry action, live-region text |
AppLiveList | AppList + AppSkeleton + RefreshIndicator | event merge logic, row flash, bounded refetch |
AppNotificationToast | AppSnackbar | priority filter, dedup window |
AppEventLog | AppDataTable + AppEmptyState | monospace stream, filter, pause |
AppBottomSheet (status sheet) | AppBottomSheet | state chip + last-event row |
RealtimeClient | — (non-visual) | socket lifecycle, handshake, rooms |
3. State mapping
All surfaces map the five realtime states to the shared state vocabulary (idle / loading / success / empty / error — 00-shared/03 legend):
| Realtime state | Shared vocabulary mapping |
|---|---|
| idle | idle |
| connecting | loading (indeterminate, non-blocking) |
| connected | success |
| reconnecting | loading (indeterminate) + warning tint |
| offline | error (offline subtype per 00-shared/10 QA baseline) |
| error-auth | error (auth subtype — distinct copy, no destructive tone) |
4. Typography
- Debug log:
font.mono(00-shared/02),text.sm, tabular numerals for timestamps. - Banner/toast:
text.mdtitle +text.smbody per shared type scale.
5. Motion (from 00-shared/08)
| Interaction | Motion |
|---|---|
| banner in/out | m-fast slide from top (16 dp) |
| dot pulse | 1 s ease-in-out loop, only in connecting/reconnecting |
| row flash | 200 ms ease-out highlight |
| toast | m-fast fade+slide, 5 s dwell |
| status sheet | m-base slide-up, content m-fast fade |
6. Accessibility (from 00-shared/09)
- Status never color-only: dot always paired with label/tooltip/live-region text.
- Banner content in live region; retry button reachable by keyboard (tab order after app bar).
- Toast auto-dismiss must not destroy meaning: bell badge persists as the durable signal.
- Debug view: monospace rows readable with text-scale up to 200%; filter field labeled.
- Reduced-motion: pulse and row-flash degrade to static states (00-shared/09).
7. QA alignment (from 00-shared/10)
- Realtime surfaces follow the shared QA baseline (offline/error matrix) plus module cases in 14_QA_Checklist.md.
12 — API Mapping (WS / Realtime Module)
- 1. REST surface: none
- 2. Transport & handshake
- 3. Client → Server events (the only wire inputs)
- 4. Server → Client events
- 5. Known gaps (all verified in source)
- 6. Planned socket consumers
- 7. Versioning & errors
Honest statement: the ws module exposes NO REST endpoints. It is a socket contract only. General API conventions live in 00-shared/07; this file maps the socket contract 1:1 to source, and marks every gap explicitly.
1. REST surface: none
| Method | Path | Status |
|---|---|---|
| — | — | No HTTP endpoints. The module consists of gateway + bridge only (ws.module.ts:6-9). Auth is via JWT on the socket handshake, not via any REST call of this module. |
2. Transport & handshake
| Aspect | Contract | Source |
|---|---|---|
| Endpoint | ws(s)://<host>/ws (socket.io namespace /ws) | ws.gateway.ts:20-23 |
| Auth | Bearer-style access token in handshake.auth.token or handshake.query.token | ws.gateway.ts:37-39 |
| Verification | JwtService.verify with JWT_ACCESS_SECRET; payload {sub, tenantId, roles} | ws.gateway.ts:42-48 |
| Failure | socket disconnected, no error frame | ws.gateway.ts:54-56 |
| CORS | origin: '*', credentials true | ws.gateway.ts:22 |
3. Client → Server events (the only wire inputs)
| Event | Payload | Effect | Source |
|---|---|---|---|
subscribe | room: string (any) | join room; future broadcastToRoom deliveries | ws.gateway.ts:63-68,80-82 |
unsubscribe | room: string (any) | leave room | ws.gateway.ts:70-74 |
No other client messages are handled; unknown events are ignored. No acks, no errors.
4. Server → Client events
| Event name | Payload envelope | Delivery | Source |
|---|---|---|---|
{eventType} — any domain event type, dot-notation (API_STANDARDS.md:642-658), e.g. notification.created, payment.completed, attendance.updated, homework.published | { eventType: string, occurredAt: ISO-8601, payload: object } | tenant:{tenantId} room — all connected sockets of the tenant | ws-bridge.service.ts:16-22; ws.gateway.ts:76-78 |
Details:
- The bridge relays every
EventBusevent (events/event-bus.service.ts:11-14) for the event's tenant — there is no server-side allow-list of event types. - The envelope is a subset of
DomainEvent(events/domain-event.interface.ts:1-8):actorIdandcorrelationIdare dropped by the bridge (ws-bridge.service.ts:17-21) — gap G3. broadcastToRoom(room, event, data)exists (ws.gateway.ts:80-82) and is used for ad-hoc room fan-out, but nothing in source calls it today.
5. Known gaps (all verified in source)
| # | Gap | Evidence | Impact / recommendation |
|---|---|---|---|
| G1 | No ws.* permissions in RBAC | ALL_PERMISSIONS (permissions.constants.ts:1-97) contains no ws.* entries | Admin debug view has no permission gate; add ws.monitor when debug surfaces ship |
| G2 | subscribe accepts any room string, no allow-list/RBAC | ws.gateway.ts:65,71 | Any authenticated user can join room X if another module broadcasts there; enforce room-prefix allow-list + permission check |
| G3 | Bridge drops actorId/correlationId | ws-bridge.service.ts:17-21 vs domain-event.interface.ts:4-6 | Client dedup uses entityId heuristics; forward correlationId |
| G4 | No per-user room / private delivery | rooms = tenant + ad-hoc (ws.gateway.ts:50,63-68) | Sensitive notifications must define user:{id} convention + RBAC |
| G5 | No Redis pub/sub adapter for sockets | src/infrastructure/redis/ has only REDIS_CLIENT (redis.constants.ts:1-2, redis.module.ts:12-27); no socket.io adapter in src | Multi-instance deployments will fan out only within one process; add socket.io Redis adapter when scaling (plan flags 10k-connection risk, IMPLEMENTATION_PLAN.md:842) |
| G6 | No heartbeat tuning in source | gateway sets no pingInterval/pingTimeout | Relies on socket.io defaults (25 s / 20 s); tune for proxy timeouts |
| G7 | No server-side event allow-list / per-event subscription | bridge relays everything (ws-bridge.service.ts:16-22) | Clients filter client-side; adds bandwidth; plan per-eventType subscription |
| G8 | roles unused | set at connect (ws.gateway.ts:48) but never read for routing | future per-role delivery; do not rely on it today |
6. Planned socket consumers
| Consumer | Contract | Status |
|---|---|---|
| Communication (messages) | realtime delivery via WsModule | planned — docs/IMPLEMENTATION_PLAN.md:119 |
| Async job progress | clients subscribe via WS instead of polling (API_STANDARDS.md:579) | planned |
| Transport live tracking | transport.* events | planned — docs/IMPLEMENTATION_PLAN.md:229 |
| Notifications | push + in-app channels | planned — docs/IMPLEMENTATION_PLAN.md:231 |
7. Versioning & errors
- No versioning (no REST). Event names follow dot-notation and are additive; clients
must ignore unknown
eventTypes (06 §6). - Error signalling is connection-level only (disconnect on auth failure,
ws.gateway.ts:54-56). No application error channel — add one for G-series fixes.
13 — State Management (WS / Realtime Module)
- 1.
RealtimeState(Bloc state) - 2. Event routing
- 3. Reconnect state machine (source-grounded)
- 4. Room membership state
- 5. Persistence
- 6. Testing strategy (client)
How the realtime layer plugs into client state management (Bloc). Shared conventions: 00-shared/06 (State Management). The module owns one
RealtimeBloc; everything else subscribes. Server-side state (rooms, sockets) is ephemeral and resettable by design — there is no persistence layer in source (gateway holds only live socket references).
1. RealtimeState (Bloc state)
| State | Meaning | Exposes |
|---|---|---|
RealtimeIdle | app start, never connected | — |
RealtimeConnecting | socket opening, handshake in flight | attempt # |
RealtimeConnected | handshake OK, in tenant room (ws.gateway.ts:50) | userId, tenantId (from auth store) |
RealtimeReconnecting | retrying after unexpected close | attempt #, nextBackoffMs |
RealtimeOffline | retries exhausted / transport dead | lastError, manual retry available |
RealtimeAuthError | handshake rejected (ws.gateway.ts:54-56) | needs token refresh |
Single cubit/bloc, app-scoped (DI singleton), mirrored by AppRealtimeStatusDot
(07 §1). No screen holds connection state locally — never a second source of truth.
2. Event routing
RealtimeClient.events (Stream<WsEnvelope>)
→ RealtimeBloc.router (pure function: envelope → typed app event)
→ feature blocs subscribe:
NotificationBloc ← notification.created
FeesDashboardBloc ← payment.completed
AttendanceBloc ← attendance.updated
HomeworkBloc ← homework.published
CommunicationBloc ← communication.* (planned)
AdminRealtimeBloc ← all (debug view, proposed)
Rules:
- Router is a pure map
eventType → AppEvent; unknown types → ignored (06§6 — additive protocol). - Feature blocs own their merge semantics via
AppLiveList(06§3.1);RealtimeBlocnever touches domain data. - Subscription teardown is explicit (bloc
close()cancels stream subs) — prevents fan-out leaks on screen navigation (14Q5).
3. Reconnect state machine (source-grounded)
Facts driving the machine: server verifies JWT on every connection (ws.gateway.ts:42-44);
disconnects silently on failure (ws.gateway.ts:54-56); tenant room is joined
automatically (ws.gateway.ts:50) but extra rooms die with the socket
(ws.gateway.ts:63-68) — so every successful reconnect re-runs: handshake → join →
re-subscribe rooms → reconcile.
stateDiagram-v2
[*] --> Idle
Idle --> Connecting : app start / manual retry
Connecting --> Connected : handshake ok (ws.gateway.ts:42-50)
Connecting --> AuthError : verify fails (ws.gateway.ts:54-56)
Connecting --> Reconnecting : transport error
Connected --> Reconnecting : heartbeat loss / close
Connected --> Disconnecting : user logout
Reconnecting --> Connecting : backoff delay elapsed
Reconnecting --> Offline : attempts > maxBackoffCap
Offline --> Connecting : "Retry now" / backoff timer
AuthError --> Connecting : token refreshed via REST
AuthError --> SignedOut : refresh failed (06 §1.2)
Disconnecting --> Idle : socket closed cleanly
Offline --> [*] : app destroyed
Notes:
- Backoff: exponential with jitter, 1 s → 30 s cap (
15§4); jitter avoids reconnect storms (14Q1). - On
ConnectedfromReconnecting: emitRealtimeReconnected→ subscribers run reconcile (re-subscribe rooms + one refetch per visible live screen,06§3.1).
4. Room membership state
| Room | Who manages | Source |
|---|---|---|
tenant:{tenantId} | server, automatic | ws.gateway.ts:50 |
| extra rooms | client, re-applied after each reconnect | ws.gateway.ts:63-68 |
RealtimeBloc keeps a Set<String> subscribedRooms; after every Connected, it replays
subscribe for each — this is the only piece of state that survives reconnects by design.
5. Persistence
- No socket state is persisted (no replay — fire-and-forget bridge,
ws-bridge.service.ts:16-22). - Token source: shared auth store (secure storage);
RealtimeBlocreads it at connect and after refresh — never stores its own copy (single source of truth per00-shared/06). - Last-envelope metadata (eventType, occurredAt) kept in-memory for the status sheet
(
05§4) — non-critical, dropped on restart.
6. Testing strategy (client)
RealtimeBlocunit tests with a fakeRealtimeClient(envelope-in → state-out).- Router tests: every mapped eventType → typed event; unknown → ignored.
- State machine tests: full reconnect matrix incl. jitter bounds, auth-error→refresh.
AppLiveListmerge tests: hit/miss/insert/refetch-bound (pure logic, no socket).
14 — QA Checklist (WS / Realtime Module)
- Q1 — Reconnect storms (client + server)
- Q2 — Auth on reconnect
- Q3 — Event fan-out leaks (tenant isolation)
- Q4 — Heartbeat loss (silent half-open connection)
- Q5 — Event fan-out leaks (client side)
- Q6 — Subscribe / unsubscribe semantics
- Q7 — Reconnect resubscription
- Q8 — Malformed payloads & unknown events
- Q9 — Payload integrity
- Q10 — Scale (planned, per plan)
- Q11 — Broadcast correctness after redeploy
- Q12 — Logging hygiene
- Test surface summary
Test cases for the realtime layer. Server side (Jest) + client side (widget/unit). Shared baseline: 00-shared/10. Every case lists pass criteria. Source anchors included; a "source" gap means the behavior is defined by us, not the server.
Q1 — Reconnect storms (client + server)
- Scenario: 100 clients behind the same NAT lose connectivity simultaneously and all retry in lockstep.
- Pass: reconnects spread over time (client backoff jitter, 13 §3, 15 §4); server accepts them without CPU/connection spikes; no tenant event lost after each reconnect.
- Server evidence:
handleConnectionlogs connect (ws.gateway.ts:51-53); verify log rate stays linear, not bursty. - Client: no two consecutive retries with identical delay; cap 30 s (13).
Q2 — Auth on reconnect
- Scenario: session token expires mid-connection; socket drops; client reconnects with the stale token.
- Pass: server closes the socket (
ws.gateway.ts:42-44,54-56); client entersAuthError(never infinite loop), refreshes token via REST, reopens socket, resumes in tenant room (ws.gateway.ts:50). - Negative: refresh fails → sign-out path with snackbar (06 §1.2), no retry loop.
- Regression: missing token (neither
authnorquery) → clean disconnect (ws.gateway.ts:37-40).
Q3 — Event fan-out leaks (tenant isolation)
- Scenario: tenant A emits
payment.completed; tenant B is connected concurrently. - Pass: only sockets in
tenant:{A}receive it (ws-bridge.service.ts:16-22,ws.gateway.ts:76-78); B receives nothing. - Multi-socket: same user with 2 tabs in A receives exactly 2 copies (client dedups).
- This is the highest-severity test on the list — isolation is the module's core security boundary (04 §2).
Q4 — Heartbeat loss (silent half-open connection)
- Scenario: device sleeps; TCP stays half-open; no data flows.
- Pass: server pings time out (socket.io heartbeat defaults — **untuned in source,
G6 12); server cleans the dead socket (visible in disconnect log,
ws.gateway.ts:59-61); client detects loss and entersReconnecting, notOfflineforever. - Tune check:
pingInterval/pingTimeoutexplicitly set once proxy timeouts are known (pending decision, G6).
Q5 — Event fan-out leaks (client side)
- Scenario: user navigates between 10 screens in a session.
- Pass: no duplicate Bloc subscriptions after navigation; bloc
close()cancels stream subs (13 §2); memory flat over 15 min of navigation; single toast per event (dedup window, 10 §3).
Q6 — Subscribe / unsubscribe semantics
- Scenario: user subscribes to
class:10-A, then unsubscribes, then resubscribes. - Pass:
broadcastToRoomreaches exactly the subscribed sockets (ws.gateway.ts:80-82); unsubscribe stops delivery (ws.gateway.ts:70-74); double-subscribe is idempotent; non-string payload ignored (ws.gateway.ts:65,71). - Security negative:
subscribeto arbitrary roomtenant:otheror__admin— no server rejection today (G2); document as known-risk until allow-list lands.
Q7 — Reconnect resubscription
- Scenario: connected with extra rooms → network flap → reconnect.
- Pass: client re-
subscribes all extra rooms after handshake (rooms die with socket,ws.gateway.ts:63-68; client replay 13 §4); tenant room auto-restored (ws.gateway.ts:50); one reconcile refetch per visible live screen (06 §3.1).
Q8 — Malformed payloads & unknown events
- Scenario: server sends envelope with wrong shape, or an unknown eventType.
- Pass: client ignores + logs eventType only (06 §6); app never crashes; unknown
eventTyperouted to no bloc; payload size cap (e.g. 1 MB) prevents memory blowup.
Q9 — Payload integrity
- Scenario: event carries entityId; REST fetch of that entity.
- Pass: live row update matches REST data after reconcile; insert/refetch-bound logic per 06 §3.1; aggregate tiles recompute without refetch.
Q10 — Scale (planned, per plan)
- Scenario: 10k concurrent connections (plan flags this as a risk —
docs/IMPLEMENTATION_PLAN.md:842). - Pass (when executed): single-instance target met OR Redis pub/sub adapter added (currently absent — G5 12); broadcast latency p95 < 1 s; reconnect storm Q1 holds.
Q11 — Broadcast correctness after redeploy
- Scenario: server restarts with new bridge; clients reconnect.
- Pass: bridge re-activates on
onModuleInit(ws-bridge.service.ts:15-23); no double registration (EventBusonAnynot stacked — verify listener count after N restarts).
Q12 — Logging hygiene
- Pass: no token, payload content, or PII in gateway/bridge logs; only userId/tenantId
- eventType (
ws.gateway.ts:51-53,59-61; 10 §8). Grep CI check.
- eventType (
Test surface summary
| Layer | Tooling | Cases |
|---|---|---|
| Server (gateway) | Jest + @nestjs/websockets mock | handshake ok/fail, room join, subscribe validation, broadcast target |
| Server (bridge) | Jest | onAny wiring, envelope shape, single registration |
| Server (e2e) | socket.io-client against test server | Q1, Q3, Q6, Q11 (requires Mongo+Redis, npm run test:e2e) |
| Client | widget tests + fake client | Q2, Q5, Q7, Q8 |
15 — Flutter Implementation Guide (WS / Realtime Module)
- 1. Transport choice — read this first (honest note)
- 2. Connection setup
- 3. Envelope handling
- 4. Auto-reconnect with backoff (state machine in
13§3) - 5. Heartbeat
- 6. Bloc integration
- 7. Offline UX wiring
- 8. Security checklist
- 9. Testing
How to implement the realtime client in Flutter. Shared architecture: 00-shared/11 (Flutter App Architecture). State machine it must implement: 13.
1. Transport choice — read this first (honest note)
The server is socket.io (ws.gateway.ts:10-11), which speaks the engine.io
handshake + protocol, not plain RFC 6455 WebSocket. Two options:
| Option | Package | Fit |
|---|---|---|
| A — recommended | socket_io_client | Full protocol parity: engine.io handshake, long-polling fallback, built-in reconnect, auth.token field, namespaces (/ws) |
| B | web_socket_channel (raw WS) | Only works if the server runs with transports: ['websocket'] only and the client reproduces the engine.io sid handshake dance — fragile; not recommended for production |
This guide uses web_socket_channel as requested, but the raw-WebSocket path must
be validated against the engine.io upgrade flow before adoption; if parity is not
achievable, switch to option A — the RealtimeClient abstraction (07 §6) makes the
swap a one-file change.
2. Connection setup
final uri = Uri.parse(
'$WS_SCHEME://$WS_HOST/ws/socket.io/?token=$token' // query.token path (ws.gateway.ts:37-39)
);
final channel = IOWebSocketChannel.connect(uri);
- Prefer the query token with
web_socket_channel(noauthmap available on raw sockets;socket_io_clientcan useauth: {'token': ...}instead — both accepted by source:ws.gateway.ts:37-39). WS_SCHEME=wssin prod; namespace path/ws(ws.gateway.ts:20-23).- Token from the secure auth store; never log the URI (contains token,
10§8). - Verify failure server-side = close (
ws.gateway.ts:54-56) — treat any earlydoneduring handshake asAuthErroruntil proven otherwise (13§1).
3. Envelope handling
class WsEnvelope {
final String eventType;
final DateTime occurredAt;
final Map<String, dynamic> payload;
// validate exactly these 3 keys — anything else = protocol violation (06 §6)
}
Server emits {eventType, occurredAt, payload} (ws-bridge.service.ts:17-21). Route
via the pure RealtimeBloc router (13 §2). Ignore unknown eventTypes.
4. Auto-reconnect with backoff (state machine in 13 §3)
Future<void> _runReconnectLoop() async {
var attempt = 0;
while (_shouldRetry) {
await Future<void>.delayed(backoffDelay(attempt)); // 1s * 2^attempt, cap 30s
final ok = await _connectOnce();
if (ok) return;
attempt++;
if (attempt >= 5 && !await _refreshToken()) { // auth on reconnect (Q2)
_emit(RealtimeAuthError()); return; // → sign-out path (06 §1.2)
}
}
_emit(RealtimeOffline()); // manual "Retry now" resumes
}
Duration backoffDelay(int n) {
final base = min(1 << n, 30) * 1.seconds; // exponential, cap 30s
return base + Random().nextInt(250).milliseconds; // jitter → no storms (Q1)
}
Rules:
- On every successful reconnect: re-
subscribeextra rooms (rooms die with the socket,ws.gateway.ts:63-68) then run one reconcile refetch per visible live screen (06§3.1). - Token expiry check before reconnecting (
06§6); refresh via REST when needed.
5. Heartbeat
- Server side runs socket.io ping/pong (defaults; untuned, G6). On raw WS, you must
detect silent half-open loss yourself: no frame (incl. pong) for
pingTimeout+grace → close channel →Reconnecting.IOWebSocketChanneldoes not heartbeat — add aTimerwatchdog if using option B (14Q4).
6. Bloc integration
class RealtimeBloc extends Cubit<RealtimeState> {
RealtimeBloc(this._client) {
_sub = _client.events.listen(_route); // envelope → typed app events (13 §2)
_client.state.listen(emit);
}
// close(): cancel _sub — no fan-out leaks after navigation (Q5)
}
Feature blocs subscribe to router output only; AppLiveList (07 §3) owns merge
semantics; never mutate lists during active edits (10 §2).
7. Offline UX wiring
AppRealtimeStatusDot+AppConnectionBannerbind toRealtimeBloc.state(11§3); banner only foroffline/authError; live-region announcements (00-shared/09).- Status sheet
(proposed)reads last-envelope metadata held by the bloc (13§5).
8. Security checklist
- Token only from secure storage; URI built at runtime (never in code/logs).
- No payload content in logs (
10§8); debug view redacts (09§5). - Validate envelope shape before routing (
14Q8); cap payload size. - Reject/ignore any server message that isn't a valid envelope.
9. Testing
RealtimeClientbehind a fake for widget tests (Q2, Q5, Q7).- Backoff unit test: delays bounded, jittered, no duplicates (Q1).
- Integration:
socket_io_clientagainst the real e2e server (npm run test:e2e,14test-surface table).